@cartanova/qgrid-cli 2.5.3 → 2.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.ko.md +7 -2
  2. package/README.md +7 -2
  3. package/bundle/dist/application/monit/monit.frame.js +3 -3
  4. package/bundle/dist/application/qgrid/oauth.js +1 -1
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +36 -14
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +19 -5
  7. package/bundle/dist/application/qgrid/tool-emulation-schema.js +93 -0
  8. package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
  9. package/bundle/dist/application/request-log/request-log.model.js +3 -3
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
  11. package/bundle/dist/application/token/token.model.js +2 -2
  12. package/bundle/dist/server-error-handler.js +15 -0
  13. package/bundle/dist/sonamu.config.js +4 -7
  14. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +4 -3
  15. package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
  16. package/bundle/dist/utils/providers/common/json-schema-keywords.js +77 -0
  17. package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
  18. package/bundle/dist/utils/providers/common/strictifier.js +332 -42
  19. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
  20. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
  21. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
  22. package/bundle/src/application/monit/monit.frame.ts +1 -1
  23. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
  24. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +337 -0
  25. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +45 -25
  26. package/bundle/src/application/qgrid/qgrid.frame.test.ts +580 -8
  27. package/bundle/src/application/qgrid/qgrid.frame.ts +28 -1
  28. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
  29. package/bundle/src/application/qgrid/tool-emulation-schema.ts +177 -0
  30. package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
  31. package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
  32. package/bundle/src/server-error-handler.ts +22 -0
  33. package/bundle/src/sonamu.config.test.ts +37 -0
  34. package/bundle/src/sonamu.config.ts +2 -5
  35. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +44 -0
  36. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +2 -2
  37. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
  38. package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
  39. package/bundle/src/utils/providers/common/json-schema-keywords.ts +72 -0
  40. package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
  41. package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
  42. package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
  43. package/bundle/src/utils/providers/common/strictifier.ts +520 -52
  44. package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
  45. package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
  46. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
  47. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
  48. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
  49. package/bundle/web-dist/client/assets/{index-DXdGBfB8.js → index-CN_kMswh.js} +3 -3
  50. package/bundle/web-dist/client/assets/{logs-D_j1yxC4.js → logs-6nYj9OKL.js} +1 -1
  51. package/bundle/web-dist/client/assets/{monit-CIToyoxW.js → monit-CaPH9Uwc.js} +1 -1
  52. package/bundle/web-dist/client/assets/{routes-Dd21A-wi.js → routes-Dh5HPi-b.js} +1 -1
  53. package/bundle/web-dist/client/assets/{show-DfePWRbc.js → show-Be_LVd9d.js} +1 -1
  54. package/bundle/web-dist/client/assets/{tokens-B4MV2fdk.js → tokens-CAi3zu_0.js} +1 -1
  55. package/bundle/web-dist/client/index.html +1 -1
  56. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  57. package/package.json +1 -1
  58. package/skills/qgrid/references/ai-sdk-provider-contract.md +34 -2
  59. package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -0
  60. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  61. package/skills/qgrid/references/decision-rationale.md +10 -1
  62. package/skills/qgrid/references/docs-workflow.md +9 -0
  63. package/skills/qgrid/references/repo-map.md +2 -1
  64. package/skills/qgrid/references/tool-calling-and-multiturn.md +145 -6
  65. package/skills/qgrid/references/verification-and-debugging.md +16 -1
@@ -0,0 +1,746 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { type JsonSchema, strictify } from "./strictifier";
4
+
5
+ describe("strictify schema-bearing siblings", () => {
6
+ it("strictifies both $defs and legacy definitions", () => {
7
+ const result = strictify({
8
+ $defs: {
9
+ Current: {
10
+ type: "object",
11
+ properties: { value: { type: "string" } },
12
+ },
13
+ },
14
+ definitions: {
15
+ Legacy: {
16
+ type: "object",
17
+ properties: { count: { type: "number" } },
18
+ },
19
+ },
20
+ });
21
+
22
+ expect(result.$defs).toMatchObject({
23
+ Current: {
24
+ required: ["value"],
25
+ additionalProperties: false,
26
+ },
27
+ });
28
+ expect(result.definitions).toMatchObject({
29
+ Legacy: {
30
+ required: ["count"],
31
+ additionalProperties: false,
32
+ },
33
+ });
34
+ });
35
+
36
+ it("strictifies object properties and a sibling combinator", () => {
37
+ const result = strictify({
38
+ type: "object",
39
+ properties: {
40
+ direct: {
41
+ type: "object",
42
+ properties: { value: { type: "string" } },
43
+ },
44
+ },
45
+ required: ["direct"],
46
+ allOf: [
47
+ {
48
+ type: "object",
49
+ properties: { combined: { type: "string" } },
50
+ },
51
+ ],
52
+ });
53
+
54
+ expect(result).toMatchObject({
55
+ required: ["direct"],
56
+ additionalProperties: false,
57
+ properties: {
58
+ direct: {
59
+ required: ["value"],
60
+ additionalProperties: false,
61
+ },
62
+ },
63
+ allOf: [
64
+ {
65
+ required: ["combined"],
66
+ additionalProperties: false,
67
+ },
68
+ ],
69
+ });
70
+ });
71
+
72
+ it("strictifies multiple combinator keywords on the same node", () => {
73
+ const result = strictify({
74
+ anyOf: [
75
+ {
76
+ type: "object",
77
+ properties: { any: { type: "string" } },
78
+ },
79
+ ],
80
+ oneOf: [
81
+ {
82
+ type: "object",
83
+ properties: { one: { type: "string" } },
84
+ },
85
+ ],
86
+ allOf: [
87
+ {
88
+ type: "object",
89
+ properties: { all: { type: "string" } },
90
+ },
91
+ ],
92
+ });
93
+
94
+ expect(result.anyOf).toMatchObject([
95
+ { required: ["any"], additionalProperties: false },
96
+ ]);
97
+ expect(result.oneOf).toMatchObject([
98
+ { required: ["one"], additionalProperties: false },
99
+ ]);
100
+ expect(result.allOf).toMatchObject([
101
+ { required: ["all"], additionalProperties: false },
102
+ ]);
103
+ });
104
+
105
+ it("strictifies draft 2020-12 prefixItems tuple members", () => {
106
+ const result = strictify({
107
+ type: "array",
108
+ prefixItems: [
109
+ {
110
+ type: "object",
111
+ properties: { label: { type: "string" } },
112
+ },
113
+ { type: "integer" },
114
+ ],
115
+ minItems: 2,
116
+ maxItems: 2,
117
+ });
118
+
119
+ expect(result.prefixItems).toMatchObject([
120
+ {
121
+ required: ["label"],
122
+ additionalProperties: false,
123
+ },
124
+ { type: "integer" },
125
+ ]);
126
+ });
127
+
128
+ it("preserves and strictifies legacy tuple arrays in items", () => {
129
+ const result = strictify({
130
+ type: "array",
131
+ items: [
132
+ {
133
+ type: "object",
134
+ properties: { label: { type: "string" } },
135
+ },
136
+ { type: "integer" },
137
+ ],
138
+ });
139
+
140
+ expect(Array.isArray(result.items)).toBe(true);
141
+ expect(result.items).toMatchObject([
142
+ {
143
+ required: ["label"],
144
+ additionalProperties: false,
145
+ },
146
+ { type: "integer" },
147
+ ]);
148
+ });
149
+
150
+ it("normalizes a generated draft-07 tuple for OpenAI without losing positions", () => {
151
+ const result = strictify(
152
+ {
153
+ type: "array",
154
+ items: [
155
+ {
156
+ type: "object",
157
+ properties: { label: { type: "string" } },
158
+ },
159
+ { type: "integer" },
160
+ ],
161
+ },
162
+ { provider: "openai" },
163
+ );
164
+
165
+ expect(result).toMatchObject({
166
+ prefixItems: [
167
+ {
168
+ required: ["label"],
169
+ additionalProperties: false,
170
+ },
171
+ { type: "integer" },
172
+ ],
173
+ items: { type: "string" },
174
+ minItems: 2,
175
+ maxItems: 2,
176
+ });
177
+ expect(Array.isArray(result.items)).toBe(false);
178
+ expect(result).not.toHaveProperty("additionalItems");
179
+ });
180
+
181
+ it("rejects positional tuples on Anthropic instead of weakening item positions", () => {
182
+ expect(() =>
183
+ strictify(
184
+ {
185
+ type: "array",
186
+ items: [
187
+ {
188
+ type: "object",
189
+ properties: { label: { type: "string" } },
190
+ },
191
+ { type: "integer" },
192
+ ],
193
+ },
194
+ { provider: "anthropic" },
195
+ ),
196
+ ).toThrow(/positional tuple schemas are not supported on Anthropic/);
197
+ });
198
+
199
+ it.each([
200
+ ["missing", undefined],
201
+ ["nullable union", ["array", "null"]],
202
+ ] as const)(
203
+ "rejects an OpenAI positional tuple with %s type instead of bypassing normalization",
204
+ (_label, type) => {
205
+ expect(() =>
206
+ strictify(
207
+ {
208
+ ...(type === undefined ? {} : { type }),
209
+ items: [{ type: "string" }, { type: "integer" }],
210
+ },
211
+ { provider: "openai" },
212
+ ),
213
+ ).toThrow(/positional tuple schemas must declare type "array"/);
214
+ },
215
+ );
216
+
217
+ it.each([
218
+ ["missing", undefined],
219
+ ["nullable union", ["array", "null"]],
220
+ ] as const)(
221
+ "rejects an Anthropic positional tuple with %s type before provider dispatch",
222
+ (_label, type) => {
223
+ expect(() =>
224
+ strictify(
225
+ {
226
+ ...(type === undefined ? {} : { type }),
227
+ prefixItems: [{ type: "string" }, { type: "integer" }],
228
+ },
229
+ { provider: "anthropic" },
230
+ ),
231
+ ).toThrow(/positional tuple schemas are not supported on Anthropic/);
232
+ },
233
+ );
234
+
235
+ it.each([
236
+ ["not", { not: { type: "array", items: [{ type: "string" }] } }],
237
+ ["if", { if: { type: "array", items: [{ type: "string" }] } }],
238
+ ["contains", { contains: { type: "array", items: [{ type: "string" }] } }],
239
+ [
240
+ "patternProperties",
241
+ {
242
+ patternProperties: {
243
+ "^tuple$": { type: "array", items: [{ type: "string" }] },
244
+ },
245
+ },
246
+ ],
247
+ [
248
+ "dependentSchemas",
249
+ {
250
+ dependentSchemas: {
251
+ tuple: { type: "array", items: [{ type: "string" }] },
252
+ },
253
+ },
254
+ ],
255
+ [
256
+ "dependencies",
257
+ {
258
+ dependencies: {
259
+ tuple: { type: "array", items: [{ type: "string" }] },
260
+ },
261
+ },
262
+ ],
263
+ ] as const)(
264
+ "finds an Anthropic positional tuple nested under %s",
265
+ (_keyword, nestedSchema) => {
266
+ expect(() =>
267
+ strictify(
268
+ {
269
+ type: "object",
270
+ ...nestedSchema,
271
+ },
272
+ { provider: "anthropic" },
273
+ ),
274
+ ).toThrow(/positional tuple schemas are not supported on Anthropic/);
275
+ },
276
+ );
277
+
278
+ it.each([
279
+ ["not", { not: { type: "array", items: [{ type: "string" }] } }],
280
+ [
281
+ "patternProperties",
282
+ {
283
+ patternProperties: {
284
+ "^tuple$": { type: "array", items: [{ type: "string" }] },
285
+ },
286
+ },
287
+ ],
288
+ ] as const)(
289
+ "rejects an OpenAI positional tuple under unsupported %s without rewriting it",
290
+ (_keyword, nestedSchema) => {
291
+ expect(() =>
292
+ strictify(
293
+ {
294
+ type: "object",
295
+ ...nestedSchema,
296
+ },
297
+ { provider: "openai" },
298
+ ),
299
+ ).toThrow(/positional tuple schemas are not supported in this schema position/);
300
+ },
301
+ );
302
+
303
+ it.each(["$defs", "definitions"] as const)(
304
+ "rejects an OpenAI reference from not into %s before globally normalizing its target",
305
+ (keyword) => {
306
+ const refPrefix = keyword === "$defs" ? "$defs" : "definitions";
307
+ expect(() =>
308
+ strictify(
309
+ {
310
+ type: "object",
311
+ [keyword]: {
312
+ Forbidden: {
313
+ type: "object",
314
+ properties: { value: { const: 0 } },
315
+ required: ["value"],
316
+ },
317
+ },
318
+ not: { $ref: `#/${refPrefix}/Forbidden` },
319
+ },
320
+ { provider: "openai" },
321
+ ),
322
+ ).toThrow(/schema references are not supported in this schema position/);
323
+ },
324
+ );
325
+
326
+ it("normalizes a positional tuple definition referenced from a positive property", () => {
327
+ const result = strictify(
328
+ {
329
+ type: "object",
330
+ properties: {
331
+ tuple: { $ref: "#/$defs/Tuple" },
332
+ },
333
+ required: ["tuple"],
334
+ $defs: {
335
+ Tuple: {
336
+ type: "array",
337
+ items: [{ type: "string" }],
338
+ },
339
+ },
340
+ },
341
+ { provider: "openai" },
342
+ );
343
+
344
+ expect((result.$defs as Record<string, JsonSchema>).Tuple).toMatchObject({
345
+ prefixItems: [{ type: "string" }],
346
+ minItems: 1,
347
+ maxItems: 1,
348
+ });
349
+ });
350
+
351
+ it.each(["openai", "anthropic"] as const)(
352
+ "rejects a %s ref target hidden in a literal JSON position",
353
+ (provider) => {
354
+ expect(() =>
355
+ strictify(
356
+ {
357
+ type: "object",
358
+ properties: {
359
+ payload: { $ref: "#/$defs/Carrier/enum/0" },
360
+ },
361
+ required: ["payload"],
362
+ $defs: {
363
+ Carrier: {
364
+ enum: [
365
+ {
366
+ type: "array",
367
+ items: [{ type: "string" }],
368
+ },
369
+ ],
370
+ },
371
+ },
372
+ },
373
+ { provider },
374
+ ),
375
+ ).toThrow(/\$ref target must be the document root or a definition root/);
376
+ },
377
+ );
378
+
379
+ it.each(["openai", "anthropic"] as const)(
380
+ "rejects a %s positive ref to a non-normalizable schema position",
381
+ (provider) => {
382
+ expect(() =>
383
+ strictify(
384
+ {
385
+ type: "object",
386
+ properties: {
387
+ payload: { $ref: "#/if" },
388
+ },
389
+ required: ["payload"],
390
+ if: {
391
+ type: "object",
392
+ properties: { secret: { type: "string" } },
393
+ },
394
+ },
395
+ { provider },
396
+ ),
397
+ ).toThrow(/\$ref target must be the document root or a definition root/);
398
+ },
399
+ );
400
+
401
+ it.each([
402
+ [
403
+ "an optional property",
404
+ {
405
+ type: "object",
406
+ properties: {
407
+ optionalSource: { type: "string" },
408
+ requiredAlias: { $ref: "#/properties/optionalSource" },
409
+ },
410
+ required: ["requiredAlias"],
411
+ },
412
+ ],
413
+ [
414
+ "a legacy tuple member",
415
+ {
416
+ type: "object",
417
+ properties: {
418
+ alias: { $ref: "#/$defs/Tuple/items/0" },
419
+ },
420
+ required: ["alias"],
421
+ $defs: {
422
+ Tuple: {
423
+ type: "array",
424
+ items: [{ type: "string" }],
425
+ },
426
+ },
427
+ },
428
+ ],
429
+ [
430
+ "a legacy tuple tail",
431
+ {
432
+ type: "object",
433
+ properties: {
434
+ alias: { $ref: "#/$defs/Tuple/additionalItems" },
435
+ },
436
+ required: ["alias"],
437
+ $defs: {
438
+ Tuple: {
439
+ type: "array",
440
+ items: [{ type: "string" }],
441
+ additionalItems: { type: "number" },
442
+ },
443
+ },
444
+ },
445
+ ],
446
+ ] as const)("rejects a ref target inside %s before its pointer can change", (_label, schema) => {
447
+ expect(() => strictify(schema, { provider: "openai" })).toThrow(
448
+ /\$ref target must be the document root or a definition root/,
449
+ );
450
+ });
451
+
452
+ it("resolves escaped local JSON Pointer tokens to positive definitions", () => {
453
+ const result = strictify(
454
+ {
455
+ type: "object",
456
+ properties: {
457
+ value: { $ref: "#/$defs/Slash~1Tilde~0Value" },
458
+ },
459
+ required: ["value"],
460
+ $defs: {
461
+ "Slash/Tilde~Value": { type: "string" },
462
+ },
463
+ },
464
+ { provider: "openai" },
465
+ );
466
+
467
+ expect((result.properties as Record<string, JsonSchema>).value).toEqual({
468
+ $ref: "#/$defs/Slash~1Tilde~0Value",
469
+ });
470
+ });
471
+
472
+ it("treats reused JavaScript objects the same as their serialized JSON value", () => {
473
+ const reusedSchema = { type: "string" };
474
+ const input = {
475
+ type: "object",
476
+ properties: {
477
+ value: { $ref: "#/$defs/Value" },
478
+ },
479
+ required: ["value"],
480
+ $defs: { Value: reusedSchema },
481
+ not: reusedSchema,
482
+ };
483
+ const serializedCopy = JSON.parse(JSON.stringify(input)) as JsonSchema;
484
+
485
+ expect(strictify(input, { provider: "openai" })).toEqual(
486
+ strictify(serializedCopy, { provider: "openai" }),
487
+ );
488
+ });
489
+
490
+ it("does not materialize inherited schema keywords or resolve refs through them", () => {
491
+ const inheritedDefinitions = {
492
+ $defs: {
493
+ Injected: { type: "string" },
494
+ },
495
+ };
496
+ const input = Object.assign(Object.create(inheritedDefinitions) as JsonSchema, {
497
+ type: "object",
498
+ properties: {
499
+ value: { $ref: "#/$defs/Injected" },
500
+ },
501
+ required: ["value"],
502
+ });
503
+
504
+ expect(() => strictify(input, { provider: "anthropic" })).toThrow(
505
+ /\$ref does not resolve inside the caller schema/,
506
+ );
507
+
508
+ const withoutRef = Object.assign(Object.create(inheritedDefinitions) as JsonSchema, {
509
+ type: "object",
510
+ properties: {},
511
+ required: [],
512
+ });
513
+ const result = strictify(withoutRef, { provider: "anthropic" });
514
+ expect(Object.hasOwn(result, "$defs")).toBe(false);
515
+ });
516
+
517
+ it.each([
518
+ ["external ref", { $ref: "https://example.com/schema.json" }, /only local root-relative/],
519
+ ["anchor ref", { $ref: "#named" }, /only local root-relative/],
520
+ ["missing target", { $ref: "#/$defs/Missing" }, /does not resolve/],
521
+ ["non-string ref", { $ref: 7 }, /\$ref must be a string/],
522
+ ["resource id", { $id: "urn:example:schema" }, /\$id is not supported/],
523
+ ["legacy resource id", { id: "legacy-id" }, /id is not supported/],
524
+ ["anchor", { $anchor: "named" }, /\$anchor is not supported/],
525
+ ["dynamic ref", { $dynamicRef: "#node" }, /\$dynamicRef is not supported/],
526
+ ["recursive ref", { $recursiveRef: "#" }, /\$recursiveRef is not supported/],
527
+ ] as const)("rejects unsupported %s features", (_label, propertySchema, message) => {
528
+ expect(() =>
529
+ strictify(
530
+ {
531
+ type: "object",
532
+ properties: { value: propertySchema },
533
+ required: ["value"],
534
+ $defs: {},
535
+ },
536
+ { provider: "openai" },
537
+ ),
538
+ ).toThrow(message);
539
+ });
540
+
541
+ it("preserves a negative not subschema instead of tightening and reversing its meaning", () => {
542
+ const negativeSchema = {
543
+ properties: {
544
+ value: { const: 0 },
545
+ },
546
+ required: ["value"],
547
+ };
548
+ const result = strictify(
549
+ {
550
+ type: "object",
551
+ properties: {
552
+ value: { type: "number" },
553
+ tag: { type: "string" },
554
+ },
555
+ required: ["value", "tag"],
556
+ not: negativeSchema,
557
+ },
558
+ { provider: "openai" },
559
+ );
560
+
561
+ expect(result.not).toEqual(negativeSchema);
562
+ });
563
+
564
+ it("preserves a typed draft-07 tuple rest for OpenAI", () => {
565
+ const result = strictify(
566
+ {
567
+ type: "array",
568
+ items: [{ type: "string" }],
569
+ additionalItems: {
570
+ type: "object",
571
+ properties: { value: { type: "integer" } },
572
+ },
573
+ },
574
+ { provider: "openai" },
575
+ );
576
+
577
+ expect(result).toMatchObject({
578
+ prefixItems: [{ type: "string" }],
579
+ items: {
580
+ required: ["value"],
581
+ additionalProperties: false,
582
+ },
583
+ minItems: 1,
584
+ });
585
+ expect(result.maxItems).toBeUndefined();
586
+ expect(result).not.toHaveProperty("additionalItems");
587
+ });
588
+
589
+ it("rejects an explicitly unrestricted tuple rest for OpenAI", () => {
590
+ for (const additionalItems of [
591
+ true,
592
+ {},
593
+ { description: "accept anything" },
594
+ { $comment: "accept anything" },
595
+ ]) {
596
+ expect(() =>
597
+ strictify(
598
+ {
599
+ type: "array",
600
+ items: [{ type: "string" }],
601
+ additionalItems,
602
+ },
603
+ { provider: "openai" },
604
+ ),
605
+ ).toThrow(/unrestricted tuple rest is not supported/);
606
+ }
607
+ });
608
+
609
+ it("rejects an unrestricted tuple member for OpenAI", () => {
610
+ for (const item of [
611
+ {},
612
+ { description: "accept anything" },
613
+ { $schema: "http://json-schema.org/draft-07/schema#" },
614
+ ]) {
615
+ expect(() =>
616
+ strictify(
617
+ {
618
+ type: "array",
619
+ items: [item],
620
+ },
621
+ { provider: "openai" },
622
+ ),
623
+ ).toThrow(/tuple member must define a provider-representable schema/);
624
+ }
625
+ });
626
+
627
+ it("normalizes a fixed prefixItems tuple for OpenAI", () => {
628
+ const result = strictify(
629
+ {
630
+ type: "array",
631
+ prefixItems: [{ type: "string" }, { type: "integer" }],
632
+ },
633
+ { provider: "openai" },
634
+ );
635
+
636
+ expect(result).toMatchObject({
637
+ prefixItems: [{ type: "string" }, { type: "integer" }],
638
+ items: { type: "string" },
639
+ minItems: 2,
640
+ maxItems: 2,
641
+ });
642
+ expect(Array.isArray(result.items)).toBe(false);
643
+ });
644
+
645
+ it.each(["openai", "anthropic"] as const)("normalizes an empty tuple for %s", (provider) => {
646
+ const result = strictify(
647
+ {
648
+ type: "array",
649
+ items: [],
650
+ },
651
+ { provider },
652
+ );
653
+
654
+ expect(result).toMatchObject({
655
+ items: { type: "string" },
656
+ minItems: 0,
657
+ maxItems: 0,
658
+ });
659
+ expect(result).not.toHaveProperty("prefixItems");
660
+ });
661
+
662
+ it("preserves explicit optional bounds while closing a draft-07 tuple", () => {
663
+ const result = strictify(
664
+ {
665
+ type: "array",
666
+ items: [{ type: "string" }, { type: "integer" }],
667
+ additionalItems: false,
668
+ minItems: 1,
669
+ maxItems: 99,
670
+ },
671
+ { provider: "openai" },
672
+ );
673
+
674
+ expect(result).toMatchObject({
675
+ minItems: 1,
676
+ maxItems: 2,
677
+ });
678
+ });
679
+
680
+ it("rejects an implicit draft-07 tail when an explicit bound reaches beyond the tuple", () => {
681
+ expect(() =>
682
+ strictify(
683
+ {
684
+ type: "array",
685
+ items: [{ type: "string" }, { type: "integer" }],
686
+ maxItems: 3,
687
+ },
688
+ { provider: "openai" },
689
+ ),
690
+ ).toThrow(/implicit tuple rest cannot be represented/);
691
+ });
692
+
693
+ it("rejects mixed-dialect tuple keywords", () => {
694
+ expect(() =>
695
+ strictify(
696
+ {
697
+ type: "array",
698
+ prefixItems: [{ type: "string" }],
699
+ additionalItems: false,
700
+ },
701
+ { provider: "openai" },
702
+ ),
703
+ ).toThrow(/additionalItems cannot be combined with prefixItems/);
704
+ });
705
+
706
+ it("removes a stale draft declaration from the OpenAI dispatch copy without mutation", () => {
707
+ const input = {
708
+ $schema: "http://json-schema.org/draft-07/schema#",
709
+ type: "array",
710
+ items: [{ type: "string" }],
711
+ };
712
+ const before = structuredClone(input);
713
+ const result = strictify(input, { provider: "openai" });
714
+
715
+ expect(result).not.toHaveProperty("$schema");
716
+ expect(input).toEqual(before);
717
+ });
718
+
719
+ it("preserves prototype-sensitive property names as own data properties", () => {
720
+ const propertyNames = ["__proto__", "constructor", "prototype"];
721
+ const properties = JSON.parse(
722
+ '{"__proto__":{"type":"string"},"constructor":{"type":"integer"},"prototype":{"type":"boolean"}}',
723
+ ) as Record<string, JsonSchema>;
724
+
725
+ const result = strictify({
726
+ type: "object",
727
+ properties,
728
+ required: propertyNames,
729
+ });
730
+ const strictProperties = result.properties as Record<string, JsonSchema>;
731
+
732
+ for (const propertyName of propertyNames) {
733
+ expect(Object.prototype.hasOwnProperty.call(strictProperties, propertyName)).toBe(true);
734
+ }
735
+ expect(Object.getPrototypeOf(strictProperties)).toBe(Object.prototype);
736
+ expect(strictProperties.__proto__).toEqual({ type: "string" });
737
+
738
+ const serializedProperties = (
739
+ JSON.parse(JSON.stringify(result)) as {
740
+ properties: Record<string, JsonSchema>;
741
+ }
742
+ ).properties;
743
+ expect(Object.keys(serializedProperties)).toEqual(propertyNames);
744
+ expect(serializedProperties.__proto__).toEqual({ type: "string" });
745
+ });
746
+ });