argsbarg 6.1.7 → 6.1.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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [6.1.8] - 2026-07-27
11
+
12
+ ### Changed
13
+
14
+ - **JSON Schema validation** — `inputSchema` and `program.appConfig` validation uses `@cfworker/json-schema`; draft is resolved from each schema’s `$schema` (default Draft-07). Hand-written schemas may opt into 2019-09 or 2020-12 (`$defs` supported). Docs updated for multi-draft and Zod interop.
15
+
10
16
  ## [6.1.7] - 2026-07-24
11
17
 
12
18
  ### Changed
@@ -859,7 +865,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
859
865
  - Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
860
866
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
861
867
 
862
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.1.7...HEAD
868
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.1.8...HEAD
869
+ [6.1.8]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.8
863
870
  [6.1.7]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.7
864
871
  [6.1.6]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.6
865
872
  [6.1.5]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.5
package/docs/README.md CHANGED
@@ -8,7 +8,7 @@ Start here to pick the right guide.
8
8
  | **Authoring a `CliProgram`** (humans or agents) | [cli-program.md](cli-program.md) — schema, formats, headless, `read*Flags` |
9
9
  | **JSON stdout / `outputSchema`** | [output-schema.md](output-schema.md) — codegen pipeline, JSDoc, narrowing |
10
10
  | **App config / `program.appConfig`** | [config-schema.md](config-schema.md) — flat JSON file, `ctx.appConfig`, codegen |
11
- | **JSON Schema subset (validation)** | [json-schema-subset.md](json-schema-subset.md) — supported keywords for config and `inputSchema` |
11
+ | **JSON Schema validation** | [json-schema-subset.md](json-schema-subset.md) — Draft-07 / 2019-09 / 2020-12 (`$schema` on each schema; default Draft-07) |
12
12
  | **Exposing MCP tools** | [mcp.md](mcp.md) — stdio server, `inputSchema`, varargs, `configure --sync` |
13
13
  | **HTTP tool server** | [http-server.md](http-server.md) — `myapp http`, endpoints, curl examples |
14
14
  | **Shipping configure / agent artifacts** | [configure.md](configure.md) — Homebrew + `myapp configure --sync` |
@@ -111,7 +111,7 @@ Descriptions and schemas are copied into MCP tools, HTTP OpenAPI, and generated
111
111
  - For shape discovery: HTTP agents load **`docs openapi`** or `GET /openapi.json`; MCP agents use **`docs cli-schema`**; load full **`docs cli`** only when prose is needed.
112
112
  - Skill **`reference.md`** uses a compact CLI guide (no inline `outputSchema` JSON) — see [bundled-docs.md](bundled-docs.md#agent-artifact-contract).
113
113
 
114
- Validation keywords: [json-schema-subset.md](json-schema-subset.md).
114
+ Validation: [json-schema-subset.md](json-schema-subset.md) (Draft-07 default; 2019-09 / 2020-12 when `$schema` is set — including Zod-generated schemas).
115
115
 
116
116
  ## Well-known option names
117
117
 
@@ -293,7 +293,7 @@ For nested tool bodies (e.g. invoice template data), declare a matching property
293
293
 
294
294
  **Precedence:** if `--invoice` is set, the flag value wins and stdin is not read.
295
295
 
296
- Use **`ctx.jsonOpt("invoice")`**, **`ctx.inputs`**, or **`ctx.inputsAs<MyInput>()`** — all synchronous. Argsbarg reads piped stdin before calling the handler when a `pipable` Json flag is omitted. When `leaf.inputSchema` is set, argsbarg validates merged inputs **before the handler runs** (same JSON Schema subset as `program.appConfig`); `ctx.inputs` returns the cached result.
296
+ Use **`ctx.jsonOpt("invoice")`**, **`ctx.inputs`**, or **`ctx.inputsAs<MyInput>()`** — all synchronous. Argsbarg reads piped stdin before calling the handler when a `pipable` Json flag is omitted. When `leaf.inputSchema` is set, argsbarg validates merged inputs **before the handler runs** (same engine as `program.appConfig`; draft from `$schema` on the schema object); `ctx.inputs` returns the cached result.
297
297
 
298
298
  At most one `pipable` Json option per leaf. Json option names must appear in `inputSchema.properties` when a custom `inputSchema` is set.
299
299
 
@@ -321,7 +321,7 @@ When the entire tool body is JSON (no CLI flags), set **`kind: "json"`** on the
321
321
 
322
322
  Example CLI: `jq '{format:"pdf", invoice:.}' data.json | myapp render-invoice`
323
323
 
324
- See [output-schema.md](output-schema.md) for schemagen `inputType`, [http-server.md](http-server.md) for HTTP tool bodies, and [json-schema-subset.md](json-schema-subset.md) for supported schema keywords.
324
+ See [output-schema.md](output-schema.md) for schemagen `inputType`, [http-server.md](http-server.md) for HTTP tool bodies, and [json-schema-subset.md](json-schema-subset.md) for validation drafts, Zod interop, and keyword notes.
325
325
 
326
326
  `CliLeafInputs` is intentionally untyped at the framework level. Narrow in your app (`read*Flags(ctx)` returning a typed struct) rather than expecting inference from `satisfies CliLeaf`.
327
327
 
@@ -48,7 +48,7 @@ await cli.run();
48
48
 
49
49
  **`_bindings`** — reserved top-level metadata: `{ "_bindings": { "apiToken": "env" } }`. Set via wizard (Enter to use env), `configure set --from-env`, or `ctx.appConfig.set` (marks `file`). Optional keys can be bound to `skip`.
50
50
 
51
- **Validation at runtime** — argsbarg validates the config file and `configure set` / `ctx.appConfig.set` against the effective JSON Schema ([supported subset](json-schema-subset.md)). Partial writes (bindings only, single-key updates) skip required-property checks.
51
+ **Validation at runtime** — argsbarg validates the config file and `configure set` / `ctx.appConfig.set` against the effective JSON Schema ([validation](json-schema-subset.md)). Draft is chosen from `jsonSchema.$schema` (default Draft-07). Partial writes (bindings only, single-key updates) skip required-property checks.
52
52
 
53
53
  See [cli-program.md — Configuration](cli-program.md#configuration-programappconfig) for resolution order, bootstrap timing, and `configure get`/`set`.
54
54
 
@@ -67,7 +67,7 @@ export interface CliAppConfigEntry {
67
67
 
68
68
  export interface CliAppConfig {
69
69
  commands?: boolean | { enabled?: boolean; mcpSet?: boolean };
70
- jsonSchema?: Record<string, unknown>; // draft-07 block schema
70
+ jsonSchema?: Record<string, unknown>; // JSON Schema block; include $schema to opt into 2019-09 / 2020-12
71
71
  entries: Record<string, CliAppConfigEntry>;
72
72
  }
73
73
  ```
@@ -155,7 +155,7 @@ flowchart LR
155
155
  end
156
156
  subgraph runtime [Runtime]
157
157
  Program["program.appConfig.jsonSchema"]
158
- Validate["argsbarg runtime subset validator"]
158
+ Validate["@cfworker/json-schema (draft from $schema)"]
159
159
  end
160
160
  src --> Script --> Gen --> Json
161
161
  Gen --> Index --> Program --> Validate
@@ -180,16 +180,19 @@ export interface AppConfig {
180
180
 
181
181
  Wire on the program root: `import { AppConfigSchema } from "./config/__generated__"`.
182
182
 
183
- ### Supported AppConfig shapes (argsbarg runtime validator)
183
+ ### Supported AppConfig shapes (runtime validation)
184
184
 
185
- | Supported (v1) | Deferred |
185
+ Validation is [@cfworker/json-schema](https://www.npmjs.com/package/@cfworker/json-schema) with draft from `$schema` (default Draft-07). See [json-schema-subset.md](json-schema-subset.md) for drafts, Zod interop, and limits.
186
+
187
+ | Commonly used | Notes |
186
188
  | --- | --- |
187
- | `type`, `properties`, `required`, `additionalProperties` | remote `$ref` |
188
- | `enum`, `const`, `default` | complex `if`/`then`/`else` |
189
- | local `#/definitions` + `$ref` | full draft-2020-12 |
190
- | `anyOf` / `oneOf` (basic) | |
189
+ | `type`, `properties`, `required`, `additionalProperties` | |
190
+ | `enum`, `const` | `default` is not applied at validation time |
191
+ | local `#/definitions` + `$ref` (Draft-07) or `#/$defs` + `$ref` (2020-12) | remote `$ref` not supported |
192
+ | `anyOf` / `oneOf` / `allOf` | |
191
193
  | `items`, `minItems`, `maxItems` | |
192
194
  | `minimum`, `maximum`, `minLength`, `maxLength`, `pattern` | |
195
+ | `format` | includes argsbarg `comma-list` |
193
196
 
194
197
  ## Minimal example (no schemagen)
195
198
 
package/docs/decisions.md CHANGED
@@ -33,7 +33,7 @@ Zod may actually cause more complexity and little/no gain for consumers.
33
33
  Yes Zod implements some features we do custom for JSON-SCHEMA, but is opinionated, less flexible, and would actually explode complexity in some situations. Zod could be a win for consumers if they require substantial, complex validation -- but then that may not convert well to json-schema anyways and json-schema generation is a big win.
34
34
 
35
35
  1. Argsbarg does a lot of schema patching/manipulation to create different schemas per target (cli-schema.json, MCP contract, openapi.json). This would be harder with Zod
36
- 2. Consumers can already use zod if they want by using Zod's to-json-schema features to convert when passing to Argsbarg. So we aren't actually alienating / thwarting consumers from using Zod anyways.
36
+ 2. Consumers can use Zod by converting to JSON Schema (`zod-to-json-schema`, `z.toJSONSchema()`) and passing the result to `inputSchema` / `appConfig.jsonSchema`. Argsbarg resolves the validator draft from each schema’s `$schema` (Draft-07 default; 2019-09 / 2020-12 when set).
37
37
  3. For uses like API pass-through, proxy, dynamic schemas, Zod may actually explode complexity for consumers.
38
38
  4. Our TS->json-schema approach is actually easier and better in many cases
39
39
  - Just write plain typescript, done.
@@ -1,39 +1,65 @@
1
- # JSON Schema subset
1
+ # JSON Schema validation
2
2
 
3
- Argsbarg validates `program.appConfig`, leaf `inputSchema`, and related documents with a **custom Draft-07 subset** in [`src/config/validate.ts`](../src/config/validate.ts). There is no runtime dependency on a full JSON Schema validator.
3
+ Argsbarg validates `program.appConfig`, leaf `inputSchema`, and related documents with [**@cfworker/json-schema**](https://www.npmjs.com/package/@cfworker/json-schema). The validator draft is chosen from each schema’s `$schema` URI (default **Draft-07** when omitted). Use this page when authoring schemas for config files, `inputSchema` on leaves, or `@sg` schemagen output.
4
4
 
5
- Use this page when authoring schemas for config files, `inputSchema` on leaves, or `@sg` schemagen output.
5
+ ## Schema draft
6
+
7
+ | `$schema` (examples) | Validator draft |
8
+ | --- | --- |
9
+ | *(omitted)* | Draft-07 (schemagen default) |
10
+ | `http://json-schema.org/draft-07/schema#` | Draft-07 |
11
+ | `https://json-schema.org/draft/2019-09/schema` | 2019-09 |
12
+ | `https://json-schema.org/draft/2020-12/schema` | 2020-12 |
13
+
14
+ Hand-written schemas may use **2020-12** with `$defs` and `#/$defs/...` `$ref`s. Schemagen (`ts-json-schema-generator`) still emits Draft-07 with `definitions`.
15
+
16
+ ## From Zod (or other generators)
17
+
18
+ You can pass JSON Schema from **`zod-to-json-schema`**, Zod v4 **`z.toJSONSchema()`**, or any tool that emits a root schema with `$schema`:
19
+
20
+ | Source | Typical `$schema` | Works with argsbarg |
21
+ | --- | --- | --- |
22
+ | `zod-to-json-schema` (default) | Draft-07 | Yes — matches default when `$schema` omitted |
23
+ | `z.toJSONSchema()` (default) | 2020-12 | Yes — draft resolved from `$schema` |
24
+ | argsbarg schemagen | Draft-07 | Yes |
25
+
26
+ Set the result on `leaf.inputSchema` or `program.appConfig.jsonSchema`. Validation uses the schema object and its `$schema` URI; not every Zod feature survives JSON Schema conversion (refinements, transforms, etc.).
6
27
 
7
28
  ## Supported constructs
8
29
 
30
+ Validation uses [@cfworker/json-schema](https://www.npmjs.com/package/@cfworker/json-schema) for the draft declared by `$schema`. Schemas from schemagen and typical Zod exports commonly use:
31
+
9
32
  | Feature | Notes |
10
33
  | --- | --- |
11
34
  | `type` | `object`, `array`, `string`, `integer`, `number`, `boolean`, `null` |
12
35
  | `properties` / `required` | Object keys; `additionalProperties: false` enforced when set |
13
36
  | `items` | Homogeneous arrays; comma-separated CLI strings coerced when `items` is a primitive |
14
37
  | `enum` / `const` | Exact value checks |
15
- | `anyOf` / `oneOf` | First matching branch wins; errors surface when none match |
16
- | `$ref` | **Local only** — `#/definitions/Name` resolved within the same root document |
17
- | `definitions` | Companion to local `$ref` |
18
- | `format` | `date`, `date-time`, `duration`, `comma-list` (and related string coercions) |
38
+ | `anyOf` / `oneOf` / `allOf` | Combinators (validator-native) |
39
+ | `$ref` | **Local only** — `#/definitions/Name` (Draft-07) or `#/$defs/Name` (2019-09 / 2020-12) |
40
+ | `definitions` / `$defs` | Companion to local `$ref` (draft-dependent) |
41
+ | `format` | Built-ins include `date`, `date-time`, `duration`; argsbarg registers `comma-list` |
19
42
  | `minimum` / `maximum` | Numbers and integers |
20
43
  | `minLength` / `maxLength` | Strings |
21
44
  | `pattern` | String regex (ECMAScript) |
22
45
 
23
46
  ## Partial validation
24
47
 
25
- `validateConfigDocumentPartial` validates **present keys only** — root `required` is skipped. Used for `configure set` partial writes and bootstrap flows.
48
+ `validateConfigDocumentPartial` validates **present keys only** — all `required` arrays are stripped before validation. Used for `configure set` partial writes and bootstrap flows.
26
49
 
27
50
  Leaf `inputSchema` validation uses full validation (including `required`) before the handler runs.
28
51
 
29
- ## Not supported (today)
52
+ ## Argsbarg-specific behavior
53
+
54
+ - Framework keys (`_bindings`, etc.) are omitted before config validation when `additionalProperties: false`.
55
+ - CLI `configure set` still coerces comma-separated primitives, booleans, and numbers before validation (`parseConfigSetValue`).
56
+
57
+ ## Not guaranteed
30
58
 
31
59
  - Remote `$ref` (`http://…`, other files)
32
- - `allOf`, conditional (`if`/`then`/`else`), `not`
33
- - Unevaluated / dynamic references
34
60
  - `default` application at validation time (defaults come from CLI option `default` or config bindings)
35
61
 
36
- If schemagen emits an unsupported keyword, simplify the TypeScript type or post-process the generated JSON Schema.
62
+ If schemagen emits keywords the validator rejects, simplify the TypeScript type or post-process the generated JSON Schema.
37
63
 
38
64
  ## Where validation runs
39
65
 
@@ -29,7 +29,7 @@ export const status = {
29
29
 
30
30
  **Set on the leaf only** — not under `mcpTool`.
31
31
 
32
- **Draft version** — argsbarg accepts any JSON Schema object (`type`, `properties`, `definitions`, etc.). Generators may emit draft-07 or draft 2020-12; docgen embeds the object as-is.
32
+ **Draft version** — for **`inputSchema`** and **`appConfig.jsonSchema`**, argsbarg validates using the draft declared in `$schema` (default Draft-07 when omitted). Schemas from schemagen, `zod-to-json-schema`, or `z.toJSONSchema()` may use Draft-07 or 2020-12. **`outputSchema`** is embedded in docs/MCP/OpenAPI as-is and is not runtime-validated.
33
33
 
34
34
  See [cli-program.md — Structured stdout](cli-program.md#structured-stdout) for when to use `outputSchema` vs `notes`, and [mcp.md](mcp.md) for how MCP returns parsed JSON as `structuredContent`.
35
35
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "6.1.7",
3
+ "version": "6.1.8",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
7
+ "@cfworker/json-schema": "^4",
7
8
  "ts-json-schema-generator": "^2.3.0"
8
9
  },
9
10
  "devDependencies": {
10
- "@biomejs/biome": "^2.5.0",
11
+ "@biomejs/biome": "^2.5.5",
11
12
  "@types/bun": "^1.3.12",
12
13
  "dts-bundle-generator": "^9.5.1",
13
14
  "typescript": "^5.9.3"
@@ -3,7 +3,7 @@ Tests for config/validate module behavior.
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { parseConfigSetValue, validateConfigDocument } from "./validate.ts";
6
+ import { parseConfigSetValue, resolveSchemaDraft, validateConfigDocument } from "./validate.ts";
7
7
 
8
8
  const rootSchema = {
9
9
  type: "object",
@@ -98,4 +98,48 @@ describe("config/validate", () => {
98
98
  expect(() => parseConfigSetValue("a,b", schema, rootSchema, false)).toThrow(/--json/);
99
99
  expect(parseConfigSetValue('[{"ttl":1}]', schema, rootSchema, false)).toEqual([{ ttl: 1 }]);
100
100
  });
101
+
102
+ test("resolveSchemaDraft maps $schema URIs", () => {
103
+ expect(resolveSchemaDraft({ $schema: "http://json-schema.org/draft-07/schema#" })).toBe("7");
104
+ expect(resolveSchemaDraft({ $schema: "https://json-schema.org/draft/2020-12/schema" })).toBe("2020-12");
105
+ expect(resolveSchemaDraft({ $schema: "https://json-schema.org/draft/2019-09/schema" })).toBe("2019-09");
106
+ expect(resolveSchemaDraft({})).toBe("7");
107
+ });
108
+
109
+ test("validates draft 2020-12 schemas when $schema is set", () => {
110
+ const schema = {
111
+ $schema: "https://json-schema.org/draft/2020-12/schema",
112
+ type: "object",
113
+ additionalProperties: false,
114
+ required: ["name"],
115
+ properties: {
116
+ name: { type: "string", minLength: 1 },
117
+ },
118
+ };
119
+ expect(validateConfigDocument({ name: "ok" }, schema).valid).toBe(true);
120
+ expect(validateConfigDocument({ name: "" }, schema).valid).toBe(false);
121
+ expect(validateConfigDocument({}, schema).valid).toBe(false);
122
+ });
123
+
124
+ test("validates draft 2020-12 $defs refs", () => {
125
+ const schema = {
126
+ $schema: "https://json-schema.org/draft/2020-12/schema",
127
+ type: "object",
128
+ additionalProperties: false,
129
+ required: ["item"],
130
+ properties: {
131
+ item: { $ref: "#/$defs/Item" },
132
+ },
133
+ $defs: {
134
+ Item: {
135
+ type: "object",
136
+ additionalProperties: false,
137
+ required: ["id"],
138
+ properties: { id: { type: "string" } },
139
+ },
140
+ },
141
+ };
142
+ expect(validateConfigDocument({ item: { id: "a" } }, schema).valid).toBe(true);
143
+ expect(validateConfigDocument({ item: { id: 1 } }, schema).valid).toBe(false);
144
+ });
101
145
  });
@@ -1,9 +1,10 @@
1
1
  /*
2
- Draft-07 JSON Schema subset validator for program.appConfig files.
3
- Aligned with common ts-json-schema-generator output (local $ref, objects, scalars).
2
+ JSON Schema validation for program.appConfig and leaf inputSchema (@cfworker/json-schema).
3
+ CLI value coercion for configure set remains here (comma-separated arrays, booleans, etc.).
4
4
  */
5
5
 
6
- import { parseCommaList, parseDate, parseDateTime } from "../core/formats.ts";
6
+ import { format as jsonSchemaFormats, type Schema, type SchemaDraft, Validator } from "@cfworker/json-schema";
7
+ import { parseCommaList, parseDate, parseDateTime, validateCommaList } from "../core/formats.ts";
7
8
  import { isFrameworkConfigKey } from "./bindings.ts";
8
9
 
9
10
  type JsonSchema = Record<string, unknown>;
@@ -19,248 +20,183 @@ export interface ValidateResult {
19
20
  errors: string[];
20
21
  }
21
22
 
22
- /** Validate `data` against a JSON Schema root. Returns human-readable error messages. */
23
- export function validateConfigDocument(data: unknown, rootSchema: JsonSchema): ValidateResult {
24
- const errors: string[] = [];
25
- validateValue(data, rootSchema, rootSchema, "$", errors, false);
26
- return { valid: errors.length === 0, errors };
27
- }
28
-
29
- /** Validate present keys only — skips root `required` checks (partial writes / bootstrap). */
30
- export function validateConfigDocumentPartial(data: unknown, rootSchema: JsonSchema): ValidateResult {
31
- const errors: string[] = [];
32
- validateValue(data, rootSchema, rootSchema, "$", errors, true);
33
- return { valid: errors.length === 0, errors };
34
- }
35
-
36
- function validateValue(
37
- value: unknown,
38
- schema: JsonSchema,
39
- root: JsonSchema,
40
- path: string,
41
- errors: string[],
42
- partial: boolean,
43
- ): void {
44
- const resolved = resolveSchema(schema, root);
45
- if (!resolved) {
46
- return;
47
- }
48
-
49
- if ("const" in resolved) {
50
- if (value !== resolved.const) {
51
- errors.push(`${path}: must be ${JSON.stringify(resolved.const)}`);
23
+ if (!jsonSchemaFormats["comma-list"]) {
24
+ jsonSchemaFormats["comma-list"] = (value: string) => {
25
+ try {
26
+ validateCommaList(value);
27
+ return true;
28
+ } catch {
29
+ return false;
52
30
  }
53
- return;
54
- }
31
+ };
32
+ }
55
33
 
56
- if (Array.isArray(resolved.enum)) {
57
- if (!resolved.enum.some((item) => item === value)) {
58
- errors.push(`${path}: must be one of ${JSON.stringify(resolved.enum)}`);
59
- }
60
- return;
34
+ function dataForSchemaValidation(data: unknown): unknown {
35
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
36
+ return data;
61
37
  }
62
-
63
- const anyOf = resolved.anyOf ?? resolved.oneOf;
64
- if (Array.isArray(anyOf)) {
65
- const branchErrors: string[][] = [];
66
- for (const branch of anyOf) {
67
- if (typeof branch !== "object" || branch === null || Array.isArray(branch)) {
68
- continue;
69
- }
70
- const branchErrs: string[] = [];
71
- validateValue(value, branch as JsonSchema, root, path, branchErrs, partial);
72
- if (branchErrs.length === 0) {
73
- return;
74
- }
75
- branchErrors.push(branchErrs);
38
+ const out: Record<string, unknown> = {};
39
+ for (const [key, value] of Object.entries(data)) {
40
+ if (isFrameworkConfigKey(key)) {
41
+ continue;
76
42
  }
77
- errors.push(`${path}: must match one of the allowed types`);
78
- if (branchErrors.length === 1 && branchErrors[0]?.[0]) {
79
- errors.push(branchErrors[0][0]);
80
- }
81
- return;
82
- }
83
-
84
- const types = normalizeTypes(resolved.type);
85
- if (types.length > 0 && !matchesAnyType(value, types)) {
86
- errors.push(`${path}: must be ${types.join(" or ")}`);
87
- return;
43
+ out[key] = value;
88
44
  }
45
+ return out;
46
+ }
89
47
 
90
- if (types.includes("string") || (types.length === 0 && typeof value === "string")) {
91
- validateString(value, resolved, path, errors);
48
+ function schemaWithoutRequired(schema: unknown): JsonSchema {
49
+ if (typeof schema !== "object" || schema === null) {
50
+ return schema as JsonSchema;
92
51
  }
93
- if (types.includes("number") || types.includes("integer")) {
94
- validateNumber(value, resolved, path, errors, types.includes("integer"));
52
+ if (Array.isArray(schema)) {
53
+ return schema.map(schemaWithoutRequired) as unknown as JsonSchema;
95
54
  }
96
- if (types.includes("boolean")) {
97
- if (typeof value !== "boolean") {
98
- errors.push(`${path}: must be boolean`);
55
+ const out: Record<string, unknown> = {};
56
+ for (const [key, value] of Object.entries(schema)) {
57
+ if (key === "required") {
58
+ continue;
99
59
  }
60
+ out[key] = schemaWithoutRequired(value);
100
61
  }
101
- if (types.includes("null") && value !== null) {
102
- errors.push(`${path}: must be null`);
103
- }
104
- if (types.includes("object") || (types.length === 0 && isPlainObject(value))) {
105
- validateObject(value, resolved, root, path, errors, partial);
106
- }
107
- if (types.includes("array") || (types.length === 0 && Array.isArray(value))) {
108
- validateArray(value, resolved, root, path, errors, partial);
109
- }
62
+ return out as JsonSchema;
110
63
  }
111
64
 
112
- function resolveSchema(schema: JsonSchema, root: JsonSchema): JsonSchema | undefined {
113
- const ref = schema.$ref;
114
- if (typeof ref === "string" && ref.startsWith("#/definitions/")) {
115
- const name = decodeURIComponent(ref.slice("#/definitions/".length));
116
- const definitions = root.definitions;
117
- if (typeof definitions !== "object" || definitions === null || Array.isArray(definitions)) {
118
- return schema;
119
- }
120
- const target = (definitions as Record<string, unknown>)[name];
121
- if (typeof target === "object" && target !== null && !Array.isArray(target)) {
122
- return target as JsonSchema;
123
- }
65
+ function formatInstancePath(instanceLocation: string): string {
66
+ if (instanceLocation.length === 0 || instanceLocation === "#") {
67
+ return "$";
124
68
  }
125
- return schema;
126
- }
127
-
128
- function normalizeTypes(type: unknown): string[] {
129
- if (typeof type === "string") {
130
- return [type];
69
+ if (instanceLocation.startsWith("#/")) {
70
+ return instanceLocation.slice(2).replace(/\//g, ".");
131
71
  }
132
- if (Array.isArray(type)) {
133
- return type.filter((t): t is string => typeof t === "string");
134
- }
135
- return [];
72
+ return instanceLocation;
136
73
  }
137
74
 
138
- function matchesAnyType(value: unknown, types: string[]): boolean {
139
- for (const t of types) {
140
- if (t === "null" && value === null) return true;
141
- if (t === "array" && Array.isArray(value)) return true;
142
- if (t === "object" && isPlainObject(value)) return true;
143
- if (t === "integer" && typeof value === "number" && Number.isInteger(value)) return true;
144
- if (t === "number" && typeof value === "number") return true;
145
- if (t === "boolean" && typeof value === "boolean") return true;
146
- if (t === "string" && typeof value === "string") return true;
147
- }
148
- return types.length === 0;
75
+ function formatValidationErrors(errors: { instanceLocation: string; error: string }[]): string[] {
76
+ return errors.map(({ instanceLocation, error }) => {
77
+ const path = formatInstancePath(instanceLocation);
78
+ return `${path}: ${error}`;
79
+ });
149
80
  }
150
81
 
151
- function isPlainObject(value: unknown): value is Record<string, unknown> {
152
- return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ function decodeJsonPointerSegment(segment: string): string {
83
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
153
84
  }
154
85
 
155
- function validateString(value: unknown, schema: JsonSchema, path: string, errors: string[]): void {
156
- if (typeof value !== "string") {
157
- errors.push(`${path}: must be string`);
158
- return;
86
+ /** Map a schema `$schema` URI to the @cfworker/json-schema draft (defaults to Draft-07). */
87
+ export function resolveSchemaDraft(schema: JsonSchema): SchemaDraft {
88
+ const $schema = schema.$schema;
89
+ if (typeof $schema !== "string") {
90
+ return "7";
159
91
  }
160
- if (typeof schema.minLength === "number" && value.length < schema.minLength) {
161
- errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
92
+ const normalized = $schema.toLowerCase();
93
+ if (normalized.includes("2020-12")) {
94
+ return "2020-12";
162
95
  }
163
- if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
164
- errors.push(`${path}: string longer than maxLength ${schema.maxLength}`);
96
+ if (normalized.includes("2019-09")) {
97
+ return "2019-09";
165
98
  }
166
- if (typeof schema.pattern === "string") {
167
- try {
168
- if (!new RegExp(schema.pattern).test(value)) {
169
- errors.push(`${path}: does not match pattern`);
170
- }
171
- } catch {
172
- /* invalid pattern in schema — skip */
99
+ if (normalized.includes("draft-04") || normalized.includes("draft/4")) {
100
+ return "4";
101
+ }
102
+ if (normalized.includes("draft-07") || normalized.includes("draft/7")) {
103
+ return "7";
104
+ }
105
+ return "7";
106
+ }
107
+
108
+ function resolveJsonPointer(root: JsonSchema, ref: string): unknown {
109
+ if (!ref.startsWith("#/")) {
110
+ return undefined;
111
+ }
112
+ const segments = ref
113
+ .slice(2)
114
+ .split("/")
115
+ .filter((segment) => segment.length > 0)
116
+ .map(decodeJsonPointerSegment);
117
+ let current: unknown = root;
118
+ for (const segment of segments) {
119
+ if (typeof current !== "object" || current === null || Array.isArray(current)) {
120
+ return undefined;
173
121
  }
122
+ current = (current as Record<string, unknown>)[segment];
174
123
  }
124
+ return current;
175
125
  }
176
126
 
177
- function validateNumber(value: unknown, schema: JsonSchema, path: string, errors: string[], integer: boolean): void {
178
- if (typeof value !== "number" || Number.isNaN(value)) {
179
- errors.push(`${path}: must be number`);
127
+ function attachRootCompanionSchemas(validator: Validator, root: JsonSchema, active: JsonSchema): void {
128
+ if (active === root) {
180
129
  return;
181
130
  }
182
- if (integer && !Number.isInteger(value)) {
183
- errors.push(`${path}: must be integer`);
131
+ const companion: Schema = {};
132
+ if (typeof root.definitions === "object" && root.definitions !== null && !Array.isArray(root.definitions)) {
133
+ companion.definitions = root.definitions;
184
134
  }
185
- if (typeof schema.minimum === "number" && value < schema.minimum) {
186
- errors.push(`${path}: below minimum ${schema.minimum}`);
135
+ if (typeof root.$defs === "object" && root.$defs !== null && !Array.isArray(root.$defs)) {
136
+ companion.$defs = root.$defs;
187
137
  }
188
- if (typeof schema.maximum === "number" && value > schema.maximum) {
189
- errors.push(`${path}: above maximum ${schema.maximum}`);
138
+ if (Object.keys(companion).length > 0) {
139
+ validator.addSchema(companion);
190
140
  }
191
141
  }
192
142
 
193
- function validateObject(
194
- value: unknown,
143
+ function validatorForSchema(schema: JsonSchema, root: JsonSchema, partial: boolean): Validator {
144
+ const active = partial ? schemaWithoutRequired(schema) : schema;
145
+ const validator = new Validator(active as Schema, resolveSchemaDraft(root), false);
146
+ attachRootCompanionSchemas(validator, root, active);
147
+ return validator;
148
+ }
149
+
150
+ function validateInstance(
151
+ data: unknown,
195
152
  schema: JsonSchema,
196
153
  root: JsonSchema,
197
- path: string,
198
- errors: string[],
199
154
  partial: boolean,
200
- ): void {
201
- if (!isPlainObject(value)) {
202
- errors.push(`${path}: must be object`);
203
- return;
204
- }
205
- const properties = schema.properties;
206
- const propMap =
207
- typeof properties === "object" && properties !== null && !Array.isArray(properties)
208
- ? (properties as Record<string, JsonSchema>)
209
- : undefined;
155
+ stripFrameworkKeys: boolean,
156
+ ): ValidateResult {
157
+ const validator = validatorForSchema(schema, root, partial);
158
+ const payload = stripFrameworkKeys ? dataForSchemaValidation(data) : data;
159
+ const result = validator.validate(payload);
160
+ if (result.valid) {
161
+ return { valid: true, errors: [] };
162
+ }
163
+ return { valid: false, errors: formatValidationErrors(result.errors) };
164
+ }
210
165
 
211
- if (propMap) {
212
- for (const [key, propSchema] of Object.entries(propMap)) {
213
- if (key in value) {
214
- validateValue(value[key], propSchema, root, `${path}.${key}`, errors, partial);
215
- }
216
- }
217
- }
166
+ function validateAgainstSchema(data: unknown, rootSchema: JsonSchema, partial: boolean): ValidateResult {
167
+ return validateInstance(data, rootSchema, rootSchema, partial, true);
168
+ }
218
169
 
219
- if (!partial) {
220
- const required = schema.required;
221
- if (Array.isArray(required)) {
222
- for (const key of required) {
223
- if (typeof key === "string" && !(key in value)) {
224
- errors.push(`${path}: missing required property '${key}'`);
225
- }
226
- }
227
- }
228
- }
170
+ /** Validate `data` against a JSON Schema root. Returns human-readable error messages. */
171
+ export function validateConfigDocument(data: unknown, rootSchema: JsonSchema): ValidateResult {
172
+ return validateAgainstSchema(data, rootSchema, false);
173
+ }
229
174
 
230
- if (schema.additionalProperties === false && propMap) {
231
- for (const key of Object.keys(value)) {
232
- if (isFrameworkConfigKey(key)) continue;
233
- if (!(key in propMap)) {
234
- errors.push(`${path}: unknown property '${key}'`);
235
- }
236
- }
237
- }
175
+ /** Validate present keys only — skips `required` checks (partial writes / bootstrap). */
176
+ export function validateConfigDocumentPartial(data: unknown, rootSchema: JsonSchema): ValidateResult {
177
+ return validateAgainstSchema(data, rootSchema, true);
238
178
  }
239
179
 
240
- function validateArray(
241
- value: unknown,
242
- schema: JsonSchema,
243
- root: JsonSchema,
244
- path: string,
245
- errors: string[],
246
- partial: boolean,
247
- ): void {
248
- if (!Array.isArray(value)) {
249
- errors.push(`${path}: must be array`);
250
- return;
180
+ function resolveSchema(schema: JsonSchema, root: JsonSchema): JsonSchema | undefined {
181
+ const ref = schema.$ref;
182
+ if (typeof ref !== "string" || !ref.startsWith("#/")) {
183
+ return schema;
251
184
  }
252
- if (typeof schema.minItems === "number" && value.length < schema.minItems) {
253
- errors.push(`${path}: fewer than minItems ${schema.minItems}`);
185
+ const target = resolveJsonPointer(root, ref);
186
+ if (typeof target === "object" && target !== null && !Array.isArray(target)) {
187
+ return target as JsonSchema;
254
188
  }
255
- if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
256
- errors.push(`${path}: more than maxItems ${schema.maxItems}`);
189
+ return schema;
190
+ }
191
+
192
+ function normalizeTypes(type: unknown): string[] {
193
+ if (typeof type === "string") {
194
+ return [type];
257
195
  }
258
- const items = schema.items;
259
- if (typeof items === "object" && items !== null && !Array.isArray(items)) {
260
- for (let i = 0; i < value.length; i++) {
261
- validateValue(value[i], items as JsonSchema, root, `${path}[${i}]`, errors, partial);
262
- }
196
+ if (Array.isArray(type)) {
197
+ return type.filter((t): t is string => typeof t === "string");
263
198
  }
199
+ return [];
264
200
  }
265
201
 
266
202
  export function validateParsedConfigValue(
@@ -271,10 +207,9 @@ export function validateParsedConfigValue(
271
207
  if (!propertySchema) {
272
208
  return parsed;
273
209
  }
274
- const errors: string[] = [];
275
- validateValue(parsed, propertySchema, rootSchema, "$", errors, false);
276
- if (errors.length > 0) {
277
- throw new Error(errors[0] ?? "Invalid config value");
210
+ const result = validateInstance(parsed, propertySchema, rootSchema, false, false);
211
+ if (!result.valid) {
212
+ throw new Error(result.errors[0] ?? "Invalid config value");
278
213
  }
279
214
  return parsed;
280
215
  }