CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
jsonSchemaArg.test.ts448 linesDownload Raw Back to config
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect } from 'vitest';8import * as fs from 'node:fs';9import * as os from 'node:os';10import * as path from 'node:path';11import { resolveJsonSchemaArg } from './config.js';12 13describe('resolveJsonSchemaArg', () => {14  it('returns undefined when the arg is absent', () => {15    expect(resolveJsonSchemaArg(undefined)).toBeUndefined();16  });17 18  it('parses an inline JSON literal into a schema object', () => {19    const schema = resolveJsonSchemaArg(20      '{"type":"object","properties":{"summary":{"type":"string"}}}',21    );22    expect(schema).toEqual({23      type: 'object',24      properties: { summary: { type: 'string' } },25    });26  });27 28  it('reads schema from disk via @path syntax', () => {29    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-schema-'));30    const file = path.join(tmp, 'schema.json');31    fs.writeFileSync(file, '{"type":"object"}');32    try {33      const schema = resolveJsonSchemaArg(`@${file}`);34      expect(schema).toEqual({ type: 'object' });35    } finally {36      fs.rmSync(tmp, { recursive: true, force: true });37    }38  });39 40  it('throws on empty string', () => {41    expect(() => resolveJsonSchemaArg('   ')).toThrow(/cannot be empty/);42  });43 44  it('throws on invalid JSON', () => {45    expect(() => resolveJsonSchemaArg('{not json}')).toThrow(/not valid JSON/);46  });47 48  it('throws when the parsed value is not an object', () => {49    expect(() => resolveJsonSchemaArg('[]')).toThrow(/must be a JSON object/);50    expect(() => resolveJsonSchemaArg('"just a string"')).toThrow(51      /must be a JSON object/,52    );53  });54 55  it('throws when the referenced file does not exist', () => {56    expect(() =>57      resolveJsonSchemaArg('@/this/path/does/not/exist.json'),58    ).toThrow(/could not read/);59  });60 61  it('rejects @path that resolves to a directory', () => {62    // stat-based "must be a regular file" guard. Without this, a path63    // pointing at a directory would surface a less-specific Node EISDIR64    // error from the readFileSync call (or worse, on systems where65    // readFileSync on a directory does not error).66    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-schema-dir-'));67    try {68      expect(() => resolveJsonSchemaArg(`@${tmp}`)).toThrow(69        /must be a regular file/,70      );71    } finally {72      fs.rmSync(tmp, { recursive: true, force: true });73    }74  });75 76  it('rejects @path schema files that exceed the size cap', () => {77    // Defence against a wrapper that forwards a user-supplied path into78    // `qwen --json-schema "$X"` where X is e.g. `@/dev/zero` or any79    // pathologically large file. We pre-check size via fs.statSync so the80    // huge buffer never gets allocated.81    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-schema-big-'));82    const file = path.join(tmp, 'huge.json');83    // Cap is 4 MiB; write 4 MiB + 1 byte to trip it.84    fs.writeFileSync(file, Buffer.alloc(4 * 1024 * 1024 + 1, 0x20));85    try {86      expect(() => resolveJsonSchemaArg(`@${file}`)).toThrow(87        /Refusing to read/,88      );89    } finally {90      fs.rmSync(tmp, { recursive: true, force: true });91    }92  });93 94  it('does not echo the JSON parse error message for @path source', () => {95    // The Node ≥18 SyntaxError message for `JSON.parse('hello world…')`96    // embeds a ~10-char prefix of the input. For inline JSON that's97    // fine — the user typed it themselves — but for @path it would leak98    // a prefix of the referenced file through stderr to any wrapper99    // that surfaces qwen's error output. Sanitise by emitting a generic100    // "content of <path> is not valid JSON" instead.101    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-schema-bad-'));102    const file = path.join(tmp, 'leaky.txt');103    const secretContent = 'SECRET_TOKEN_PREFIX hello world';104    fs.writeFileSync(file, secretContent);105    try {106      let caught: Error | undefined;107      try {108        resolveJsonSchemaArg(`@${file}`);109      } catch (err) {110        caught = err as Error;111      }112      expect(caught).toBeDefined();113      expect(caught!.message).toMatch(/is not valid JSON/);114      // The file's contents must NOT appear in the error message.115      expect(caught!.message).not.toContain('SECRET_TOKEN_PREFIX');116      expect(caught!.message).not.toContain('hello worl');117    } finally {118      fs.rmSync(tmp, { recursive: true, force: true });119    }120  });121 122  it('still echoes JSON parse error detail for inline (non-@path) source', () => {123    // Inline JSON is the user's own input — keeping the SyntaxError detail124    // is helpful for debugging typos, and there's no third-party file125    // content to leak.126    let caught: Error | undefined;127    try {128      resolveJsonSchemaArg('{"foo":}');129    } catch (err) {130      caught = err as Error;131    }132    expect(caught).toBeDefined();133    expect(caught!.message).toMatch(/--json-schema is not valid JSON/);134    // Should mention the JSON parser detail (echoes SyntaxError text).135    expect(caught!.message).not.toBe('--json-schema is not valid JSON: ');136  });137 138  it('throws when schema is syntactically JSON but invalid as a JSON Schema', () => {139    // The root-type check fires first for an integer `type`; drop type140    // entirely to exercise the Ajv compile-path rejection instead.141    expect(() =>142      resolveJsonSchemaArg('{"properties":{"foo":{"type":42}}}'),143    ).toThrow(/not a valid JSON Schema/);144  });145 146  it('accepts a minimal empty-object schema', () => {147    // `{}` is a valid schema that accepts anything.148    expect(resolveJsonSchemaArg('{}')).toEqual({});149  });150 151  it('accepts a draft-2020-12 schema', () => {152    const schema = resolveJsonSchemaArg(153      '{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}',154    );155    expect(schema).toBeDefined();156  });157 158  it('rejects a schema whose root type is not object', () => {159    expect(() => resolveJsonSchemaArg('{"type":"array"}')).toThrow(160      /must accept object-typed values/,161    );162    expect(() => resolveJsonSchemaArg('{"type":"string"}')).toThrow(163      /must accept object-typed values/,164    );165  });166 167  it('accepts a schema whose type array includes "object"', () => {168    // Rare but valid; don't over-restrict nullable object roots.169    const schema = resolveJsonSchemaArg('{"type":["object","null"]}');170    expect(schema).toEqual({ type: ['object', 'null'] });171  });172 173  it('accepts a schema without an explicit root type', () => {174    // Absent type is tolerated — Ajv treats it as "anything" which covers175    // the object case the model will actually submit.176    const schema = resolveJsonSchemaArg('{"properties":{"foo":{}}}');177    expect(schema).toBeDefined();178  });179 180  it('rejects root anyOf where no branch accepts object', () => {181    expect(() =>182      resolveJsonSchemaArg('{"anyOf":[{"type":"array"},{"type":"string"}]}'),183    ).toThrow(/must accept object-typed values/);184  });185 186  it('rejects root oneOf where no branch accepts object', () => {187    expect(() =>188      resolveJsonSchemaArg('{"oneOf":[{"type":"number"},{"type":"boolean"}]}'),189    ).toThrow(/must accept object-typed values/);190  });191 192  it('accepts root anyOf when at least one branch accepts object', () => {193    const schema = resolveJsonSchemaArg(194      '{"anyOf":[{"type":"object"},{"type":"string"}]}',195    );196    expect(schema).toBeDefined();197  });198 199  it('accepts nested anyOf/oneOf chains where a deep branch accepts object', () => {200    // The recursion should see through one level of nesting.201    const schema = resolveJsonSchemaArg(202      '{"anyOf":[{"oneOf":[{"type":"object"}]},{"type":"string"}]}',203    );204    expect(schema).toBeDefined();205  });206 207  it('rejects type:"object" combined with an anyOf that excludes object', () => {208    // type and anyOf are AND'd at the same level — type:"object" alone is209    // not enough if a sibling anyOf forbids every object branch. Without210    // this check the synthetic tool would register an unsatisfiable schema.211    expect(() =>212      resolveJsonSchemaArg(213        '{"type":"object","anyOf":[{"type":"string"},{"type":"number"}]}',214      ),215    ).toThrow(/must accept object-typed values/);216  });217 218  it('accepts type:"object" combined with anyOf where one branch admits object', () => {219    const schema = resolveJsonSchemaArg(220      '{"type":"object","anyOf":[{"type":"object","properties":{"a":{"type":"string"}}},{"type":"object","properties":{"b":{"type":"number"}}}]}',221    );222    expect(schema).toBeDefined();223  });224 225  it('rejects any root $ref, even with a sibling type:"object" anchor', () => {226    // Ajv applies `$ref` conjunctively with sibling keywords, so a sibling227    // `type:"object"` is NOT enough to make the schema satisfiable — when228    // the referenced subschema is non-object, the resulting AND is229    // unsatisfiable at runtime. We reject root `$ref` outright rather than230    // following the reference ourselves (local-only resolution would still231    // have to handle remote / recursive refs).232    expect(() =>233      resolveJsonSchemaArg(234        '{"$ref":"#/$defs/Foo","$defs":{"Foo":{"type":"array"}}}',235      ),236    ).toThrow(/must accept object-typed values/);237    expect(() =>238      resolveJsonSchemaArg(239        '{"type":"object","$ref":"#/$defs/Foo","$defs":{"Foo":{"type":"array"}}}',240      ),241    ).toThrow(/must accept object-typed values/);242    // Even when the referenced schema IS object-shaped, we still reject —243    // the contract for `--json-schema` is "the root schema describes the244    // tool args directly", not "follow these refs". Users wanting245    // composition should inline at the root or use `allOf`.246    expect(() =>247      resolveJsonSchemaArg(248        '{"type":"object","$ref":"#/$defs/Foo","$defs":{"Foo":{"type":"object","properties":{"a":{"type":"string"}}}}}',249      ),250    ).toThrow(/must accept object-typed values/);251  });252 253  it('rejects allOf where any branch forbids object at the root', () => {254    // allOf is conjunctive — every branch must accept object. A schema255    // like `allOf:[{type:"object"}, {type:"string"}]` is unsatisfiable.256    expect(() =>257      resolveJsonSchemaArg('{"allOf":[{"type":"object"},{"type":"string"}]}'),258    ).toThrow(/must accept object-typed values/);259  });260 261  it('accepts allOf where every branch admits object', () => {262    const schema = resolveJsonSchemaArg(263      '{"allOf":[{"type":"object","properties":{"a":{"type":"string"}}},{"type":"object","required":["a"]}]}',264    );265    expect(schema).toBeDefined();266  });267 268  it('rejects a root `not` that directly forbids object', () => {269    // `not:{type:"object"}` excludes every object value, so the schema is270    // unsatisfiable for tool-call args. Best-effort check — only inspects271    // `not.type`; deeper negated patterns fall through to Ajv at runtime.272    expect(() => resolveJsonSchemaArg('{"not":{"type":"object"}}')).toThrow(273      /must accept object-typed values/,274    );275    expect(() =>276      resolveJsonSchemaArg('{"not":{"type":["object","null"]}}'),277    ).toThrow(/must accept object-typed values/);278  });279 280  it('accepts a root `not` whose negated type does not exclude object', () => {281    // `not:{type:"string"}` only forbids strings — objects are still fine.282    const schema = resolveJsonSchemaArg('{"not":{"type":"string"}}');283    expect(schema).toBeDefined();284  });285 286  it('accepts root `not:{type:"object", ...narrowing}` because narrowing keywords leave some objects satisfiable', () => {287    // `not:{type:"object",required:["error"]}` only excludes objects288    // that have an `error` key. An object like `{}` is NOT excluded289    // (it doesn't match the `required` constraint), so the schema is290    // satisfiable for at least one object value.291    //292    // The previous parse-time check looked only at `not.type` and293    // rejected this as "must accept object-typed values" — a false294    // positive. The fix: only reject when `not` is exactly295    // `{type: ...}` with no narrowing siblings; otherwise defer to296    // Ajv at runtime.297    expect(298      resolveJsonSchemaArg('{"not":{"type":"object","required":["error"]}}'),299    ).toBeDefined();300    expect(301      resolveJsonSchemaArg(302        '{"not":{"type":"object","properties":{"k":{"type":"string"}},"required":["k"]}}',303      ),304    ).toBeDefined();305    expect(306      resolveJsonSchemaArg('{"not":{"type":"object","minProperties":1}}'),307    ).toBeDefined();308  });309 310  it('rejects a root `const` whose value is not an object', () => {311    expect(() => resolveJsonSchemaArg('{"const":1}')).toThrow(312      /must accept object-typed values/,313    );314    expect(() => resolveJsonSchemaArg('{"const":"hello"}')).toThrow(315      /must accept object-typed values/,316    );317    expect(() => resolveJsonSchemaArg('{"const":[1,2]}')).toThrow(318      /must accept object-typed values/,319    );320  });321 322  it('accepts a root `const` whose value is an object', () => {323    const schema = resolveJsonSchemaArg(324      '{"const":{"summary":"hello","risk":"low"}}',325    );326    expect(schema).toBeDefined();327  });328 329  it('rejects a root `enum` with no object members', () => {330    expect(() => resolveJsonSchemaArg('{"enum":[1,2,"three"]}')).toThrow(331      /must accept object-typed values/,332    );333    // Empty enum admits nothing — also reject.334    expect(() => resolveJsonSchemaArg('{"enum":[]}')).toThrow(335      /must accept object-typed values/,336    );337  });338 339  it('accepts a root `enum` when at least one member is an object', () => {340    const schema = resolveJsonSchemaArg(341      '{"enum":[{"summary":"a","risk":"low"},{"summary":"b","risk":"high"}]}',342    );343    expect(schema).toBeDefined();344  });345 346  it('rejects an empty root anyOf / oneOf as unsatisfiable', () => {347    expect(() => resolveJsonSchemaArg('{"anyOf":[]}')).toThrow(348      /must accept object-typed values/,349    );350    expect(() => resolveJsonSchemaArg('{"oneOf":[]}')).toThrow(351      /must accept object-typed values/,352    );353  });354 355  it('accepts boolean subschemas in anyOf where any branch is true', () => {356    // `true` matches every value (per JSON Schema 2019-09+), so it admits357    // objects. `{anyOf:[true]}` should pass.358    const a = resolveJsonSchemaArg('{"anyOf":[true]}');359    expect(a).toBeDefined();360    const b = resolveJsonSchemaArg('{"anyOf":[false,true]}');361    expect(b).toBeDefined();362    const c = resolveJsonSchemaArg('{"anyOf":[true,{"type":"string"}]}');363    expect(c).toBeDefined();364  });365 366  it('rejects anyOf where every branch is `false`', () => {367    // `false` matches nothing, so an anyOf of all-false is unsatisfiable.368    expect(() => resolveJsonSchemaArg('{"anyOf":[false]}')).toThrow(369      /must accept object-typed values/,370    );371    expect(() => resolveJsonSchemaArg('{"anyOf":[false,false]}')).toThrow(372      /must accept object-typed values/,373    );374  });375 376  it('accepts $ref nested inside anyOf / oneOf / allOf branches', () => {377    // Root $ref is rejected unconditionally (Ajv applies it conjunctively378    // with siblings), but $ref *inside* a composition branch is opaque379    // at parse time — Ajv will resolve it at runtime. Refusing nested380    // refs would block common $defs/$ref composition shapes.381    const a = resolveJsonSchemaArg(382      '{"anyOf":[{"$ref":"#/$defs/Foo"},{"type":"string"}],"$defs":{"Foo":{"type":"object"}}}',383    );384    expect(a).toBeDefined();385    const b = resolveJsonSchemaArg(386      '{"oneOf":[{"$ref":"#/$defs/A"},{"$ref":"#/$defs/B"}],"$defs":{"A":{"type":"object"},"B":{"type":"object"}}}',387    );388    expect(b).toBeDefined();389    const c = resolveJsonSchemaArg(390      '{"allOf":[{"$ref":"#/$defs/Bar"},{"type":"object"}],"$defs":{"Bar":{"type":"object"}}}',391    );392    expect(c).toBeDefined();393  });394 395  it('handles boolean subschemas in allOf', () => {396    // `true` is neutral in allOf, `false` makes the whole schema unsatisfiable.397    const ok = resolveJsonSchemaArg('{"allOf":[true,{"type":"object"}]}');398    expect(ok).toBeDefined();399    expect(() =>400      resolveJsonSchemaArg('{"allOf":[false,{"type":"object"}]}'),401    ).toThrow(/must accept object-typed values/);402    expect(() => resolveJsonSchemaArg('{"allOf":[false]}')).toThrow(403      /must accept object-typed values/,404    );405  });406 407  it('rejects if/then/else when the decidable branch admits no objects', () => {408    // `if: true` reduces root acceptance to `then`'s acceptance.409    // `if: false` reduces it to `else`'s acceptance. Object schemas in410    // `if` are runtime-decidable only and fall through to Ajv.411    expect(() =>412      resolveJsonSchemaArg('{"if":true,"then":{"type":"string"}}'),413    ).toThrow(/must accept object-typed values/);414    expect(() => resolveJsonSchemaArg('{"if":true,"then":false}')).toThrow(415      /must accept object-typed values/,416    );417    expect(() =>418      resolveJsonSchemaArg('{"if":false,"else":{"type":"array"}}'),419    ).toThrow(/must accept object-typed values/);420    expect(() => resolveJsonSchemaArg('{"if":false,"else":false}')).toThrow(421      /must accept object-typed values/,422    );423  });424 425  it('accepts if/then/else when the decidable branch admits objects', () => {426    // `if: true` + object-compatible `then` passes (parse-time427    // schemaRootAcceptsObject reduces to checking `then`).428    expect(429      resolveJsonSchemaArg('{"if":true,"then":{"type":"object"}}'),430    ).toBeDefined();431    // `if: false` + object-compatible `else`.432    expect(433      resolveJsonSchemaArg('{"if":false,"else":{"type":"object"}}'),434    ).toBeDefined();435    // Object schema for `if` — runtime-decidable; defer to Ajv. We436    // accept at parse time even when `then` excludes object, because437    // an object value may not match `if` and so isn't bound by `then`.438    expect(439      resolveJsonSchemaArg(440        '{"if":{"type":"object","properties":{"k":{"const":"x"}}},"then":{"type":"object","properties":{"v":{"type":"string"}}}}',441      ),442    ).toBeDefined();443    // (The degenerate `{if:true}` / `{if:false}` shapes — no `then` and444    // no `else` — are rejected by Ajv strict mode as meaningless rather445    // than by schemaRootAcceptsObject; that's fine.)446  });447});448 
basant307/AI_Governance_Project · CoolFace