argsbarg 7.1.1 → 7.1.2

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.1.2] - 2026-09-25
11
+
12
+ ### Added
13
+
14
+ - `mcpServer.instructions` — an optional string returned as `initialize.result.instructions`. Claude Code adds it to the system prompt of every session; Cursor writes it to `mcps/<server>/INSTRUCTIONS.md`.
15
+ - MCP protocol version negotiation: `initialize` now echoes a client's `2025-06-18` or `2024-11-05` `protocolVersion`, and answers `2025-06-18` (the newest) when the request omits it or asks for an unsupported version. Previously the server always claimed `2024-11-05` regardless of what the client sent or what fields it actually returned. `tools/list`'s `outputSchema` and `tools/call`'s `structuredContent` — both defined starting in `2025-06-18` — are now only sent for sessions that negotiated that version or later; a `2024-11-05` session no longer receives fields from a spec revision it never agreed to.
16
+ - `mcpSizeReport(root)` and `mcpServer.sizeLimits` — measures every MCP tool's `description` length and pretty-printed definition (bytes and lines) against configurable limits approximating two real client behaviors (Claude Code truncates long tool descriptions; Cursor reads each tool's synced definition file in bounded chunks), and returns warnings for anything over. `serveMcp` runs this at startup and writes warnings to stderr before the "MCP ready" line; `docs mcp` now includes a `## Tool sizes` table. Set any limit to `false` to disable that check.
17
+ - `mcpTool.notes` (`string | false`) — overrides a leaf's `notes` in the MCP tool description only; CLI `--help` continues to show the leaf's own `notes` unchanged. Useful for a note that only makes sense with `--help` in front of it, or to keep a tool's definition under a size limit.
18
+
19
+ ### Changed
20
+
21
+ - Discriminated `anyOf`/`oneOf` unions (every branch has a `properties` key with a string `const` or all-string `enum`, and the value sets are disjoint) now report only the branch matching the instance's discriminator value, instead of every branch's unrelated errors at once. A missing, non-string, or unmapped discriminator value collapses to one synthetic message naming the valid choices. Non-discriminated unions are unaffected. Along the way, two cfworker `@cfworker/json-schema` quirks are also cleaned up for every validated schema (not just discriminated unions): a spurious `additionalProperties` + `"False boolean schema."` pair it emits even for a property that's genuinely declared in `properties`, and dropping now-redundant wrapper errors (`$ref`, `properties`, `items`, `anyOf`, `oneOf`, …) once a more specific error survives underneath them. Discriminator detection also resolves each branch through `$ref` before inspecting its `properties` — schema generators such as `ts-json-schema-generator` write every `anyOf` branch as a bare `{ $ref }` into `definitions` rather than inlining it, which previously made every branch look property-less and silently fell back to the noisy full-error listing.
22
+
10
23
  ## [7.1.1] - 2026-09-25
11
24
 
12
25
  ### Added
@@ -1048,7 +1061,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
1048
1061
  - 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`).
1049
1062
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
1050
1063
 
1051
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.1.1...HEAD
1064
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.1.2...HEAD
1065
+ [7.1.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.1.2
1052
1066
  [7.1.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.1.1
1053
1067
  [7.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.1.0
1054
1068
  [7.0.11]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.11
package/docs/mcp.md CHANGED
@@ -96,9 +96,11 @@ Set `mcpServer` on the **program root only** (the `CliProgram` passed to `new Cl
96
96
  | Field | Default | Purpose |
97
97
  | --- | --- | --- |
98
98
  | `enabled` | *(required)* | Must be `true` when `mcpServer` is set |
99
+ | `instructions` | *(none)* | Returned as `initialize.result.instructions` for every negotiated protocol version. Claude Code adds it to the system prompt of every session; Cursor writes it to `mcps/<server>/INSTRUCTIONS.md`. Both cases cost context whether or not the agent ends up using this server, so keep it to a one- or two-line pointer (when to reach for this tool, and to read the accompanying skill first), not usage docs. Must be non-empty when set. |
99
100
  | `schemaResourceUri` | `<sanitized root key>://schema` | URI for the built-in schema resource |
100
101
  | `shellEnv` | on (opt-out with `false`) | Capture login-shell `env` at startup (`true` uses `$SHELL`, or pass a shell path) |
101
102
  | `resources` | `[]` | Custom `CliMcpResource` entries (additive; schema resource is always included) |
103
+ | `sizeLimits` | see [Tool sizes](#tool-sizes) | Overrides the default startup size warnings for tool descriptions, definitions, and `instructions` |
102
104
 
103
105
  MCP `serverInfo.name` and the default schema URI use the sanitized program `key` (non-alphanumeric characters become `_`). Program `version` comes from `CliProgram.version` (also used by the `version` built-in).
104
106
 
@@ -158,12 +160,14 @@ Omitted or `enabled: true` exposes the command (default). `mcpTool` is only vali
158
160
  mcpTool: {
159
161
  enabled: true,
160
162
  description: "Custom tools/list text (overrides auto-generated path + help).",
163
+ notes: false, // omit this leaf's `notes` from the MCP description; a string replaces them
161
164
  }
162
165
  ```
163
166
 
164
167
  Set **`outputSchema` on the leaf** (not under `mcpTool`) — see [cli-program.md — Structured stdout](cli-program.md#structured-stdout).
165
168
 
166
169
  - **`description`** — when set, replaces the auto-generated `path — help` description entirely.
170
+ - **`notes`** — overrides the leaf's `notes` in the MCP description only; CLI `--help` always shows the leaf's own `notes` unchanged. `false` omits notes from the MCP description entirely; a string replaces them; omit `notes` to use the leaf's `notes` as given. Useful when a note only makes sense with `--help` in front of it, or to keep a tool's definition under a size limit (see [Tool sizes](#tool-sizes)).
167
171
 
168
172
  ### Tool arguments
169
173
 
@@ -194,12 +198,37 @@ On success (`isError: false`):
194
198
 
195
199
  - **stdout** — first `content` text block with the handler’s captured stdout (raw, unchanged).
196
200
  - **stderr** — when non-empty, a second `content` text block with trimmed stderr (no prefix). The block’s position signals stderr; hosts may label it themselves.
197
- - **structuredContent** — when trimmed stdout is valid JSON, the parsed value is also returned per the [MCP tools spec](https://modelcontextprotocol.io/specification/draft/server/tools). Objects and arrays from flags like `--json` are the common case. JSON **primitives** (`true`, `42`, `"hello"`) are parsed too — a handler that prints the literal string `true` as human text would get `structuredContent: true`. Prefer objects for machine-readable output.
201
+ - **structuredContent** — when trimmed stdout is valid JSON, the parsed value is also returned per the [MCP tools spec](https://modelcontextprotocol.io/specification/draft/server/tools) — only for `2025-06-18` sessions (see [Protocol](#protocol)); `2024-11-05` sessions get `content` only. Objects and arrays from flags like `--json` are the common case. JSON **primitives** (`true`, `42`, `"hello"`) are parsed too — a handler that prints the literal string `true` as human text would get `structuredContent: true`. Prefer objects for machine-readable output.
198
202
 
199
203
  On failure (parse error, validation error, non-zero exit, thrown error), the **full** error message is returned as text content with `isError: true` (ANSI stripped, newlines preserved). HTTP JSON `{ "error": "…" }` uses the same full text. Do not collapse headless errors to the first line.
200
204
 
201
205
  Help and `docs cli-schema` are not available through tool calls; use the schema resource or run the CLI directly for those.
202
206
 
207
+ ## Tool sizes
208
+
209
+ Some hosts have their own limits on how much of a tool's `description` or full definition they'll read, independent of anything the MCP spec itself defines. `mcpSizeReport(root)` (exported from `argsbarg`) measures every tool's `description` length and pretty-printed `{name, description, inputSchema, outputSchema}` definition (bytes and lines) against configurable limits, and returns human-readable warnings for anything over. `serveMcp` runs this at startup and writes any warnings to stderr (`action: "mcp.size"`) before the "MCP ready" line; `docs mcp` includes a `## Tool sizes` table with a per-tool `ok` / `over: …` status.
210
+
211
+ Default limits — observed client behaviors, not MCP spec requirements, so they may need retuning as those clients change:
212
+
213
+ | Limit | Default | Approximates |
214
+ | --- | --- | --- |
215
+ | `descriptionChars` | `2,048` | Claude Code truncates a tool's `description` past this |
216
+ | `definitionBytes` | `51,200` | Cursor syncs each tool's full definition to a file and reads it in chunks of at most this many bytes |
217
+ | `definitionLines` | `2,000` | Same file, read in chunks of at most this many lines (whichever limit hits first) |
218
+ | `instructionsChars` | `2,048` | No specific client behavior modeled yet; a general "keep it short" budget |
219
+
220
+ Override with `mcpServer.sizeLimits`; set any field to `false` to disable that check entirely:
221
+
222
+ ```typescript
223
+ mcpServer: {
224
+ enabled: true,
225
+ sizeLimits: {
226
+ definitionBytes: 100_000, // this app's tools are legitimately large
227
+ instructionsChars: false, // don't warn on instructions length
228
+ },
229
+ }
230
+ ```
231
+
203
232
  ## Schema and custom resources
204
233
 
205
234
  The built-in schema resource (default URI `<sanitized-key>://schema`, e.g. `nested.ts` → `nested_ts://schema`) exposes your full CLI tree as JSON — the same output as `myapp docs cli-schema`. Override with `schemaResourceUri` if needed.
@@ -312,16 +341,16 @@ mcpServer: {
312
341
 
313
342
  - **Transport:** stdio, newline-delimited JSON (NDJSON).
314
343
  - **JSON-RPC:** version `2.0`.
315
- - **MCP protocol version:** `2024-11-05` (reported in `initialize`).
344
+ - **MCP protocol version negotiation:** the server supports `2025-06-18` and `2024-11-05`. `initialize` echoes `params.protocolVersion` when it's one of those; otherwise (unsupported, or omitted) it answers `2025-06-18`, the newest. The negotiated version is fixed for the lifetime of the stdio session (one `initialize` per connection) and gates `outputSchema` (`tools/list`) and `structuredContent` (`tools/call`): both are present only for `2025-06-18` sessions, since those fields are defined starting there. `2025-03-26` is not supported (it mandates JSON-RPC batching, which this server doesn't implement).
316
345
 
317
346
  ### Supported methods
318
347
 
319
348
  | Method | Description |
320
349
  | --- | --- |
321
- | `initialize` | Returns capabilities (`tools`, `resources`) and `serverInfo`. |
350
+ | `initialize` | Negotiates protocol version, returns capabilities (`tools`, `resources`), `serverInfo`, and optional `instructions`. |
322
351
  | `notifications/initialized` | Acknowledged; no response (notification). |
323
352
  | `ping` | Returns `{}`. |
324
- | `tools/list` | Lists all tools with `name`, `description`, `inputSchema`, and optional `outputSchema`. |
353
+ | `tools/list` | Lists all tools with `name`, `description`, `inputSchema`, and `outputSchema` (`2025-06-18` sessions only). |
325
354
  | `tools/call` | Runs a leaf handler; params: `name`, `arguments` (object). |
326
355
  | `resources/list` | Lists schema + custom resources. |
327
356
  | `resources/read` | Returns resource body; params: `uri`. |
package/index.d.ts CHANGED
@@ -342,6 +342,13 @@ export interface CliMcpBundleConfig {
342
342
  export interface CliMcpServerConfig {
343
343
  /** When `true`, enables the `mcp` built-in and MCP stdio server. */
344
344
  enabled: boolean;
345
+ /**
346
+ * Returned as `initialize.result.instructions`. Claude Code adds it to the system prompt of every
347
+ * session; Cursor writes it to `mcps/<server>/INSTRUCTIONS.md`. Both cases cost context whether or
348
+ * not the agent ends up using this server, so keep it to a one- or two-line pointer (e.g. when to
349
+ * reach for this tool, and to read the accompanying skill first) rather than usage documentation.
350
+ */
351
+ instructions?: string;
345
352
  /** MCP error response defaults. */
346
353
  errors?: CliMcpServerErrorsConfig;
347
354
  /** Observe-only hooks for JSON-RPC messages. */
@@ -367,6 +374,24 @@ export interface CliMcpServerConfig {
367
374
  resources?: CliMcpResource[];
368
375
  /** Optional MCP Bundle (`.mcpb`) metadata for `mcp bundle`. */
369
376
  bundle?: CliMcpBundleConfig;
377
+ /** Overrides the default startup size warnings (see {@link CliMcpSizeLimits}). */
378
+ sizeLimits?: CliMcpSizeLimits;
379
+ }
380
+ /**
381
+ * Size limits for one MCP tool's `description` and pretty-printed definition, and for `instructions`.
382
+ * Set a field to `false` to disable that check. Defaults come from two client behaviors observed in the
383
+ * wild, not from the MCP spec itself, so they may need retuning as those clients change:
384
+ * Claude Code truncates a tool's `description` past `descriptionChars`; Cursor syncs each tool's full
385
+ * definition (`{name, description, inputSchema, outputSchema}`, pretty-printed) to a file under
386
+ * `mcps/<server>/tools/<tool>.json` and its agent reads that file in chunks of at most `definitionBytes`
387
+ * bytes or `definitionLines` lines, whichever comes first — a tool at or beyond either limit is read
388
+ * incompletely on the first pass.
389
+ */
390
+ export interface CliMcpSizeLimits {
391
+ definitionBytes?: number | false;
392
+ definitionLines?: number | false;
393
+ descriptionChars?: number | false;
394
+ instructionsChars?: number | false;
370
395
  }
371
396
  /** JSON Schema for structured error responses (OpenAPI + HTTP/MCP error bodies). */
372
397
  export type CliJsonSchema = Record<string, unknown>;
@@ -481,6 +506,13 @@ export interface CliMcpToolConfig {
481
506
  * Default: auto-generated from command path and description.
482
507
  */
483
508
  description?: string;
509
+ /**
510
+ * Overrides the leaf's `notes` in the MCP description only — CLI help always shows `notes` unchanged.
511
+ * `false` omits notes from the MCP description entirely; a string replaces them. Omit to use `notes` as
512
+ * given. Useful when a note only makes sense with `--help` in front of it (a CLI-only workflow tip), or
513
+ * when the full CLI notes would push a definition past a size limit (see {@link CliMcpSizeLimits}).
514
+ */
515
+ notes?: string | false;
484
516
  }
485
517
  /** Context passed to {@link CliAppConfigEntry.resolve} for one config key. */
486
518
  export interface CliAppConfigResolveContext {
@@ -950,6 +982,30 @@ export interface PackMcpBundleOpts {
950
982
  * Requires the compiled binary to exist.
951
983
  */
952
984
  export declare function packMcpBundle(program: CliProgram, opts?: PackMcpBundleOpts): string;
985
+ /** Default {@link CliMcpSizeLimits}; see that type for what each limit approximates and why. */
986
+ export declare const DEFAULT_MCP_SIZE_LIMITS: Required<CliMcpSizeLimits>;
987
+ /** Measured size of one MCP tool's description and pretty-printed definition. */
988
+ export interface McpToolSize {
989
+ /** Pretty-printed `{name, description, inputSchema, outputSchema}`, in UTF-8 bytes. */
990
+ definitionBytes: number;
991
+ /** Line count of the same pretty-printed definition. */
992
+ definitionLines: number;
993
+ /** Character length of `description` alone. */
994
+ descriptionChars: number;
995
+ /** MCP tool name. */
996
+ name: string;
997
+ }
998
+ /** Per-tool sizes plus any warnings past {@link CliMcpSizeLimits} (defaults or `mcpServer.sizeLimits`). */
999
+ export interface McpSizeReport {
1000
+ /** Character length of `mcpServer.instructions`, or 0 when unset. */
1001
+ instructionsChars: number;
1002
+ /** One entry per MCP tool, in `tools/list` order. */
1003
+ tools: McpToolSize[];
1004
+ /** Human-readable warnings for anything past its limit; empty when everything fits. */
1005
+ warnings: string[];
1006
+ }
1007
+ /** Measures every MCP tool's description and definition size against {@link CliMcpSizeLimits}. */
1008
+ export declare function mcpSizeReport(root: CliProgram): McpSizeReport;
953
1009
  /**
954
1010
  * Resolves the user home directory without depending on `$HOME`.
955
1011
  * This is helpful for when homebrew post-install hooks run with a temporary `$HOME`.
@@ -1056,6 +1112,12 @@ export interface ServerHandleContext {
1056
1112
  mcp?: ResolvedMcpServeConfig;
1057
1113
  httpHooks?: CliHttpWireHooks;
1058
1114
  mcpHooks?: CliMcpWireHooks;
1115
+ /**
1116
+ * The MCP protocol version negotiated with `initialize`, or the newest supported version before
1117
+ * `initialize` has been handled. Later requests (`tools/list`, `tools/call`) gate version-specific
1118
+ * response fields (e.g. `outputSchema`, `structuredContent`) on this.
1119
+ */
1120
+ mcpProtocolVersion?: string;
1059
1121
  }
1060
1122
  /** Platform builtins derived from program config and runtime. */
1061
1123
  export interface CliCapabilities {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "7.1.1",
3
+ "version": "7.1.2",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
@@ -51,6 +51,163 @@ describe("config/validate", () => {
51
51
  expect(result.valid).toBe(false);
52
52
  });
53
53
 
54
+ describe("discriminated unions", () => {
55
+ const stepSchema = {
56
+ $schema: "http://json-schema.org/draft-07/schema#",
57
+ type: "object",
58
+ properties: { steps: { type: "array", items: { $ref: "#/definitions/Step" } } },
59
+ required: ["steps"],
60
+ additionalProperties: false,
61
+ definitions: {
62
+ Step: {
63
+ anyOf: [
64
+ {
65
+ type: "object",
66
+ properties: { kind: { const: "alpha" }, title: { type: "string" } },
67
+ required: ["kind", "title"],
68
+ additionalProperties: false,
69
+ },
70
+ {
71
+ type: "object",
72
+ properties: { kind: { enum: ["beta", "bravo"] }, count: { type: "number" } },
73
+ required: ["kind"],
74
+ additionalProperties: false,
75
+ },
76
+ {
77
+ type: "object",
78
+ properties: { kind: { const: "gamma" }, flag: { type: "boolean" } },
79
+ required: ["kind"],
80
+ additionalProperties: false,
81
+ },
82
+ ],
83
+ },
84
+ },
85
+ };
86
+
87
+ test("valid mix of branches passes", () => {
88
+ const result = validateConfigDocument(
89
+ {
90
+ steps: [
91
+ { kind: "alpha", title: "x" },
92
+ { kind: "beta", count: 3 },
93
+ ],
94
+ },
95
+ stepSchema,
96
+ );
97
+ expect(result.valid).toBe(true);
98
+ expect(result.errors).toEqual([]);
99
+ });
100
+
101
+ test("unknown property in the matched branch reports only that branch", () => {
102
+ const result = validateConfigDocument({ steps: [{ kind: "alpha", titel: "x" }] }, stepSchema);
103
+ expect(result.errors).toEqual([
104
+ 'steps.0: missing required property "title"',
105
+ 'steps.0: unknown property "titel" (allowed: kind, title)',
106
+ ]);
107
+ });
108
+
109
+ test("unmapped discriminator value reports one synthetic error", () => {
110
+ const result = validateConfigDocument({ steps: [{ kind: "alfa" }] }, stepSchema);
111
+ expect(result.errors).toEqual(['steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta, bravo, gamma)']);
112
+ });
113
+
114
+ test("missing discriminator reports one synthetic error", () => {
115
+ const result = validateConfigDocument({ steps: [{ title: "x" }] }, stepSchema);
116
+ expect(result.errors).toEqual(['steps.0: missing "kind" (expected one of: alpha, beta, bravo, gamma)']);
117
+ });
118
+
119
+ test("non-object instance reports one synthetic error", () => {
120
+ const result = validateConfigDocument({ steps: ["alpha"] }, stepSchema);
121
+ expect(result.errors).toEqual(['steps.0: expected an object with "kind" (one of: alpha, beta, bravo, gamma)']);
122
+ });
123
+
124
+ test("type mismatch in the matched branch reports only that field", () => {
125
+ const result = validateConfigDocument({ steps: [{ kind: "beta", count: "x" }] }, stepSchema);
126
+ expect(result.errors).toEqual(["steps.0.count: must be number (got string)"]);
127
+ });
128
+
129
+ test("a non-discriminated union still reports errors from every branch", () => {
130
+ const nonDiscriminatedSchema = {
131
+ type: "object",
132
+ properties: {
133
+ x: {
134
+ anyOf: [
135
+ { type: "object", properties: { a: { type: "string" } }, required: ["a"], additionalProperties: false },
136
+ { type: "object", properties: { b: { type: "number" } }, required: ["b"], additionalProperties: false },
137
+ ],
138
+ },
139
+ },
140
+ additionalProperties: false,
141
+ };
142
+ const result = validateConfigDocument({ x: {} }, nonDiscriminatedSchema);
143
+ expect(result.errors).toEqual(['x: missing required property "a"', 'x: missing required property "b"']);
144
+ });
145
+
146
+ test("collects one error per bad step, in order, without cross-contamination between array indices", () => {
147
+ const result = validateConfigDocument({ steps: [{ kind: "alfa" }, { kind: "beta", count: "x" }] }, stepSchema);
148
+ expect(result.errors).toEqual([
149
+ 'steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta, bravo, gamma)',
150
+ "steps.1.count: must be number (got string)",
151
+ ]);
152
+ });
153
+
154
+ test("resolves discriminators through $ref'd branches (ts-json-schema-generator output shape)", () => {
155
+ // ts-json-schema-generator (and similar tools) write each anyOf branch as a bare `{ $ref }` pointing
156
+ // into `definitions`, rather than inlining the branch schema. This is the shape gdocsmith's real
157
+ // `run` tool schema uses for its 27 step kinds, and it silently defeated discriminator detection
158
+ // (every branch looked property-less) until unionDiscriminator started resolving branch refs.
159
+ const refBranchSchema = {
160
+ $schema: "http://json-schema.org/draft-07/schema#",
161
+ type: "object",
162
+ properties: {
163
+ steps: { type: "array", items: { anyOf: [{ $ref: "#/definitions/A" }, { $ref: "#/definitions/B" }] } },
164
+ },
165
+ required: ["steps"],
166
+ additionalProperties: false,
167
+ definitions: {
168
+ A: {
169
+ type: "object",
170
+ properties: { kind: { const: "alpha" }, title: { type: "string" } },
171
+ required: ["kind", "title"],
172
+ additionalProperties: false,
173
+ },
174
+ B: {
175
+ type: "object",
176
+ properties: { kind: { const: "beta" }, count: { type: "number" } },
177
+ required: ["kind"],
178
+ additionalProperties: false,
179
+ },
180
+ },
181
+ };
182
+ const result = validateConfigDocument({ steps: [{ kind: "alfa" }] }, refBranchSchema);
183
+ expect(result.errors).toEqual(['steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta)']);
184
+ });
185
+
186
+ test("caps at 10 errors plus a count of the remainder", () => {
187
+ const manySchema = {
188
+ type: "object",
189
+ properties: {
190
+ steps: {
191
+ type: "array",
192
+ items: {
193
+ type: "object",
194
+ properties: { kind: { enum: ["a"] } },
195
+ required: ["kind"],
196
+ additionalProperties: false,
197
+ },
198
+ },
199
+ },
200
+ additionalProperties: false,
201
+ };
202
+ const result = validateConfigDocument({ steps: Array.from({ length: 12 }, () => ({})) }, manySchema);
203
+ expect(result.errors).toHaveLength(11);
204
+ expect(result.errors.slice(0, 10)).toEqual(
205
+ Array.from({ length: 10 }, (_, i) => `steps.${i}: missing required property "kind"`),
206
+ );
207
+ expect(result.errors[10]).toBe("…and 2 more errors");
208
+ });
209
+ });
210
+
54
211
  test("parseConfigSetValue coerces number and boolean", () => {
55
212
  expect(parseConfigSetValue("5", { type: "integer" }, rootSchema, false)).toBe(5);
56
213
  expect(parseConfigSetValue("true", { type: "boolean" }, rootSchema, false)).toBe(true);