diff --git a/src/analyzer.ts b/src/analyzer.ts index 7e7bc53..bf9df2d 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -1504,12 +1504,30 @@ function withNullType(schema: JSONSchema7): JSONSchema7 { * If any segment along the path is optional, the entire expression * can produce `null`/`undefined` at runtime. * + * Intrinsic array accesses (`.length`, `.[N]`) are always considered + * required — they are synthesized by the schema resolver and do not + * appear in any `required` array. + * * Example: for path `["user", "name"]`, checks both that `user` is * required in the root schema AND that `name` is required in `user`. */ function isPathFullyRequired(schema: JSONSchema7, segments: string[]): boolean { for (let i = 1; i <= segments.length; i++) { - if (!isPropertyRequired(schema, segments.slice(0, i))) return false; + if (!isPropertyRequired(schema, segments.slice(0, i))) { + // Intrinsic array access: .length and .[N] are always available + // on array schemas. They are not listed in `required` because + // they are synthesized by the schema resolver, not real properties. + if (i >= 2) { + const parentSchema = resolveSchemaPath( + schema, + segments.slice(0, i - 1), + ); + if (parentSchema && parentSchema.type === "array") { + continue; + } + } + return false; + } } return true; } diff --git a/tests/analyzer.spec.ts b/tests/analyzer.spec.ts index fd4c772..6847478 100644 --- a/tests/analyzer.spec.ts +++ b/tests/analyzer.spec.ts @@ -1122,6 +1122,60 @@ describe("analyzer", () => { required: ["l1"], }); }); + + // ── Intrinsic array access (.length, .[N]) ─────────────────────── + + test(".length on required array → integer (not nullable)", () => { + const schema: JSONSchema7 = { + type: "object", + properties: { + items: { type: "array", items: { type: "string" } }, + }, + required: ["items"], + }; + const result = analyze("{{items.length}}", schema); + expect(result.valid).toBe(true); + expect(result.outputSchema).toEqual({ type: "integer" }); + }); + + test(".[0] on required array → element type (not nullable)", () => { + const schema: JSONSchema7 = { + type: "object", + properties: { + items: { type: "array", items: { type: "number" } }, + }, + required: ["items"], + }; + const result = analyze("{{items.[0]}}", schema); + expect(result.valid).toBe(true); + expect(result.outputSchema).toEqual({ type: "number" }); + }); + + test(".length on optional array → nullable integer", () => { + const schema: JSONSchema7 = { + type: "object", + properties: { + items: { type: "array", items: { type: "string" } }, + }, + // items is NOT required + }; + const result = analyze("{{items.length}}", schema); + expect(result.valid).toBe(true); + expect(result.outputSchema).toEqual({ type: ["integer", "null"] }); + }); + + test(".[0] on optional array → nullable element type", () => { + const schema: JSONSchema7 = { + type: "object", + properties: { + items: { type: "array", items: { type: "number" } }, + }, + // items is NOT required + }; + const result = analyze("{{items.[0]}}", schema); + expect(result.valid).toBe(true); + expect(result.outputSchema).toEqual({ type: ["number", "null"] }); + }); }); describe("diagnostics include a position (loc)", () => {