basant307/AI_Governance_Project
048
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 