diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap index 6c08e65..b3ebb48 100644 --- a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -2,36 +2,36 @@ exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = ` { - "type": "2NHHHE9NR0FVT", + "type": "CRK52TZT2ZND7", "value": { "payload": { "age": 30, "name": "Alice", }, - "type": "3K9ETAPGFPE32", + "type": "5J4TDW3H4J427", }, } `; exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = ` { - "type": "BZ3TV0PTATA52", + "type": "0RJTSZR6K608G", "value": "ok", } `; exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = ` "{ - "type": "18HZGJY02WN1K", + "type": "DGB04ECT2M49Q", "value": [] }" `; exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = ` "{ - "type": "9BVX9SHACFC5J", + "type": "EQ015GFTBKQJA", "value": [ - "A0QKG4ERMXSFG" + "078KT0M8N554S" ] }" `; @@ -40,40 +40,40 @@ exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fai exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = ` { - "type": "EMX6HW4CECBGK", + "type": "E88C2CKY90ERT", "value": { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": {}, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, } `; exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = ` { - "type": "F68GWJAT9H6HP", + "type": "0GVNZDR5AC35Y", "value": { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": {}, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, } `; exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` { - "type": "A4TV1FVBBNG5M", + "type": "3XAAW976C1KT8", "value": [ { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": {}, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, ], } @@ -81,14 +81,14 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = ` { - "type": "A4TV1FVBBNG5M", + "type": "3XAAW976C1KT8", "value": [ { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": {}, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, ], } @@ -96,48 +96,48 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = ` exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = ` { - "type": "EMX6HW4CECBGK", + "type": "E88C2CKY90ERT", "value": { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": {}, - "value": "FNDM9C6P23238", + "value": "0S3E6H68VK328", }, } `; exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = ` { - "type": "92JS8RXGKDKC2", + "type": "92GB8QS47S7HS", "value": { "labels": [ "important", ], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": { "env": "prod", }, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, } `; exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = ` { - "type": "A4TV1FVBBNG5M", + "type": "3XAAW976C1KT8", "value": [ { "labels": [ "important", ], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": { "env": "prod", }, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, ], } @@ -145,18 +145,18 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = ` { - "type": "A4TV1FVBBNG5M", + "type": "3XAAW976C1KT8", "value": [ { "labels": [ "important", ], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": { "env": "prod", }, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, ], } @@ -164,62 +164,62 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = ` { - "type": "92JS8RXGKDKC2", + "type": "92GB8QS47S7HS", "value": { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": { "env": "prod", }, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, } `; exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = ` { - "type": "00YH4RTNVWTRB", + "type": "EV9W9HDVK7WX2", "value": [ { "labels": [], "name": "myapp/config", - "schema": "3K9ETAPGFPE32", + "schema": "5J4TDW3H4J427", "tags": { "env": "prod", }, - "value": "A0QKG4ERMXSFG", + "value": "078KT0M8N554S", }, ], } `; -exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=3K9ETAPGFPE32"`; +exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=5J4TDW3H4J427"`; exports[`Phase 4: Template System 4.1 template set registers template 1`] = ` { - "type": "DTNQZ90QQHA6D", + "type": "FTZ3B2BN1H3SA", "value": { - "contentHash": "DBBPX5JJ74SWZ", - "schemaHash": "3K9ETAPGFPE32", + "contentHash": "0MNS832R7RZSV", + "schemaHash": "5J4TDW3H4J427", }, } `; exports[`Phase 4: Template System 4.2 template get returns template text 1`] = ` { - "type": "8KMGVC6TG12TS", + "type": "EZ034E7H0JWFV", "value": "Name: {{ payload.name }}, Age: {{ payload.age }}", } `; exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = ` { - "type": "6RZCT3XX4J8BC", + "type": "DCVZJFMSKBGJV", "value": [ { - "contentHash": "DBBPX5JJ74SWZ", - "schemaHash": "3K9ETAPGFPE32", + "contentHash": "0MNS832R7RZSV", + "schemaHash": "5J4TDW3H4J427", }, ], } @@ -227,14 +227,14 @@ exports[`Phase 4: Template System 4.3 template list shows registered templates 1 exports[`Phase 4: Template System 4.4 template delete removes template 1`] = ` { - "type": "DBHJH385HGVTM", + "type": "3RKA4H2KVF0TF", "value": { "deleted": true, }, } `; -exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 3K9ETAPGFPE32"`; +exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 5J4TDW3H4J427"`; exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`; diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 3b8a5c8..200c120 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -219,7 +219,7 @@ async function cmdPut(args: string[]): Promise { try { const hash = await putSchema(store, payload as Record); out(await wrapEnvelope(store, "@output/put", hash)); - } catch (e) { + } catch (_e) { console.error( `Validation failed: payload in ${file} does not match schema ${typeHash}`, ); diff --git a/packages/json-cas/src/bootstrap.ts b/packages/json-cas/src/bootstrap.ts index 1e01dd4..63822bc 100644 --- a/packages/json-cas/src/bootstrap.ts +++ b/packages/json-cas/src/bootstrap.ts @@ -71,6 +71,28 @@ const BOOTSTRAP_PAYLOAD = { minItems: { type: "number" }, maxItems: { type: "number" }, uniqueItems: { type: "boolean" }, + // P2 combinators + conditionals + allOf: { + type: "array", + items: { type: "object", additionalProperties: false }, + }, + if: { type: "object", additionalProperties: false }, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable + then: { type: "object", additionalProperties: false }, + else: { type: "object", additionalProperties: false }, + patternProperties: { + type: "object", + additionalProperties: { type: "object", additionalProperties: false }, + }, + prefixItems: { + type: "array", + items: { type: "object", additionalProperties: false }, + }, + // P2 leaf constraints + multipleOf: { type: "number" }, + minProperties: { type: "number" }, + maxProperties: { type: "number" }, + default: {}, }, } as const; diff --git a/packages/json-cas/src/schema.test.ts b/packages/json-cas/src/schema.test.ts index 465814c..dd214ea 100644 --- a/packages/json-cas/src/schema.test.ts +++ b/packages/json-cas/src/schema.test.ts @@ -501,4 +501,166 @@ describe("bootstrap meta-schema self-reference", () => { const metaNode = store.get(metaHash) as CasNode; expect(metaNode.type).toBe(metaHash); }); + + // ── P2 combinators, conditionals, and leaf constraints ────────────────── + + test("accepts schema with allOf", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + allOf: [ + { type: "object", properties: { name: { type: "string" } } }, + { required: ["name"] }, + ], + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, { name: "Alice" }); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + + const bad = await store.put(hash, {}); + expect(validate(store, store.get(bad) as CasNode)).toBe(false); + }); + + test("accepts schema with if/then/else", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "object", + properties: { + kind: { type: "string" }, + value: {}, + }, + if: { properties: { kind: { const: "number" } } }, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable + then: { properties: { value: { type: "number" } } }, + else: { properties: { value: { type: "string" } } }, + }); + expect(hash).toHaveLength(13); + }); + + test("accepts schema with patternProperties", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "object", + patternProperties: { + "^x-": { type: "string" }, + }, + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, { "x-custom": "hello" }); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + }); + + test("accepts schema with prefixItems (tuple)", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + }); + expect(hash).toHaveLength(13); + }); + + test("accepts schema with multipleOf", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "number", + multipleOf: 5, + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, 15); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + + const bad = await store.put(hash, 7); + expect(validate(store, store.get(bad) as CasNode)).toBe(false); + }); + + test("accepts schema with minProperties/maxProperties", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "object", + minProperties: 1, + maxProperties: 3, + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, { a: 1, b: 2 }); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + + const empty = await store.put(hash, {}); + expect(validate(store, store.get(empty) as CasNode)).toBe(false); + }); + + test("accepts schema with default value", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "string", + default: "hello", + }); + expect(hash).toHaveLength(13); + }); + + test("rejects invalid P2 keyword types", async () => { + const store = createMemoryStore(); + await expect( + putSchema(store, { allOf: "not-array" } as never), + ).rejects.toThrow(); + await expect( + putSchema(store, { multipleOf: "five" } as never), + ).rejects.toThrow(); + await expect( + putSchema(store, { patternProperties: [1, 2] } as never), + ).rejects.toThrow(); + }); + + test("collectRefs traverses allOf sub-schemas", async () => { + const store = createMemoryStore(); + const innerSchema = await putSchema(store, { type: "string" }); + const schema = await putSchema(store, { + allOf: [ + { + type: "object", + properties: { ref: { type: "string", format: "cas_ref" } }, + }, + ], + }); + + const targetHash = await store.put(innerSchema, "target"); + const nodeHash = await store.put(schema, { ref: targetHash }); + const node = store.get(nodeHash) as CasNode; + const refList = refs(store, node); + expect(refList).toContain(targetHash); + }); + + test("collectRefs traverses patternProperties", async () => { + const store = createMemoryStore(); + const innerSchema = await putSchema(store, { type: "string" }); + const schema = await putSchema(store, { + type: "object", + patternProperties: { + "^ref_": { type: "string", format: "cas_ref" }, + }, + }); + + const targetHash = await store.put(innerSchema, "hello"); + const nodeHash = await store.put(schema, { ref_a: targetHash }); + const node = store.get(nodeHash) as CasNode; + const refList = refs(store, node); + expect(refList).toContain(targetHash); + }); + + test("collectRefs traverses prefixItems", async () => { + const store = createMemoryStore(); + const innerSchema = await putSchema(store, { type: "string" }); + const schema = await putSchema(store, { + type: "array", + prefixItems: [{ type: "string", format: "cas_ref" }, { type: "number" }], + }); + + const targetHash = await store.put(innerSchema, "hello"); + const nodeHash = await store.put(schema, [targetHash, 42]); + const node = store.get(nodeHash) as CasNode; + const refList = refs(store, node); + expect(refList).toContain(targetHash); + }); }); diff --git a/packages/json-cas/src/schema.ts b/packages/json-cas/src/schema.ts index cf30f02..924f46c 100644 --- a/packages/json-cas/src/schema.ts +++ b/packages/json-cas/src/schema.ts @@ -46,6 +46,18 @@ const ALLOWED_SCHEMA_KEYS = new Set([ "minItems", "maxItems", "uniqueItems", + // P2 combinators + conditionals (need collectRefs) + "allOf", + "if", + "then", + "else", + "patternProperties", + "prefixItems", + // P2 leaf constraints + "multipleOf", + "minProperties", + "maxProperties", + "default", ]); const JSON_SCHEMA_TYPES = new Set([ @@ -160,6 +172,44 @@ function isValidSchema(value: unknown): boolean { if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean") return false; + // P2 combinators + conditionals — recursive sub-schema checks + if ("allOf" in schema) { + if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) return false; + for (const entry of schema.allOf) { + if (!isValidSchema(entry)) return false; + } + } + + if ("if" in schema && !isValidSchema(schema.if)) return false; + if ("then" in schema && !isValidSchema(schema.then)) return false; + if ("else" in schema && !isValidSchema(schema.else)) return false; + + if ("patternProperties" in schema) { + const pp = schema.patternProperties; + if (pp === null || typeof pp !== "object" || Array.isArray(pp)) + return false; + for (const nested of Object.values(pp as Record)) { + if (!isValidSchema(nested)) return false; + } + } + + if ("prefixItems" in schema) { + if (!Array.isArray(schema.prefixItems) || schema.prefixItems.length === 0) + return false; + for (const entry of schema.prefixItems) { + if (!isValidSchema(entry)) return false; + } + } + + // P2 leaf constraints — type checks only + if ("multipleOf" in schema && typeof schema.multipleOf !== "number") + return false; + if ("minProperties" in schema && typeof schema.minProperties !== "number") + return false; + if ("maxProperties" in schema && typeof schema.maxProperties !== "number") + return false; + // "default" accepts any value — no type check needed + return true; } @@ -217,8 +267,9 @@ export function validate(store: Store, node: CasNode): boolean { /** * Recursively collect values of all properties whose schema has format: 'cas_ref'. - * Handles: direct format, anyOf (nullable refs), items (array refs), - * properties (nested objects), and additionalProperties (record refs). + * Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals), + * items + prefixItems (arrays), properties (nested objects), + * additionalProperties (record refs), and patternProperties (regex-keyed refs). */ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] { const result: Hash[] = []; @@ -237,10 +288,45 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] { return result; } - if (schema.type === "array" && schema.items && Array.isArray(value)) { - const itemSchema = schema.items as JSONSchema; - for (const item of value as unknown[]) { - result.push(...collectRefs(itemSchema, item)); + // P2: allOf — each sub-schema applies to the same value + if (Array.isArray(schema.allOf)) { + for (const sub of schema.allOf as JSONSchema[]) { + result.push(...collectRefs(sub, value)); + } + } + + // P2: if/then/else — conditional sub-schemas apply to the same value + if (schema.if && typeof schema.if === "object") { + result.push(...collectRefs(schema.if as JSONSchema, value)); + } + if (schema.then && typeof schema.then === "object") { + result.push(...collectRefs(schema.then as JSONSchema, value)); + } + if (schema.else && typeof schema.else === "object") { + result.push(...collectRefs(schema.else as JSONSchema, value)); + } + + if (schema.type === "array" && Array.isArray(value)) { + // P2: prefixItems — tuple validation, each item has its own schema + if (Array.isArray(schema.prefixItems)) { + const tupleSchemas = schema.prefixItems as JSONSchema[]; + const arr = value as unknown[]; + for (let i = 0; i < tupleSchemas.length && i < arr.length; i++) { + const ts = tupleSchemas[i]; + if (ts) result.push(...collectRefs(ts, arr[i])); + } + } + + if (schema.items) { + const itemSchema = schema.items as JSONSchema; + // When prefixItems exists, items applies only to remaining elements + const startIdx = Array.isArray(schema.prefixItems) + ? (schema.prefixItems as unknown[]).length + : 0; + const arr = value as unknown[]; + for (let i = startIdx; i < arr.length; i++) { + result.push(...collectRefs(itemSchema, arr[i])); + } } return result; } @@ -264,6 +350,23 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] { result.push(...collectRefs(addlSchema, val)); } } + + // P2: patternProperties — regex-keyed property schemas + if ( + schema.patternProperties && + typeof schema.patternProperties === "object" + ) { + const pp = schema.patternProperties as Record; + const obj = value as Record; + for (const [pat, subSchema] of Object.entries(pp)) { + const re = new RegExp(pat); + for (const [key, val] of Object.entries(obj)) { + if (re.test(key)) { + result.push(...collectRefs(subSchema, val)); + } + } + } + } } return result; diff --git a/packages/json-cas/tests/schema-validation.test.ts b/packages/json-cas/tests/schema-validation.test.ts index 0017674..30287f8 100644 --- a/packages/json-cas/tests/schema-validation.test.ts +++ b/packages/json-cas/tests/schema-validation.test.ts @@ -72,8 +72,19 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { expect(properties).not.toHaveProperty("$ref"); expect(properties).not.toHaveProperty("$id"); expect(properties).not.toHaveProperty("$defs"); - expect(properties).not.toHaveProperty("allOf"); expect(properties).not.toHaveProperty("not"); + + // P2 keywords should be present + expect(properties).toHaveProperty("allOf"); + expect(properties).toHaveProperty("if"); + expect(properties).toHaveProperty("then"); + expect(properties).toHaveProperty("else"); + expect(properties).toHaveProperty("patternProperties"); + expect(properties).toHaveProperty("prefixItems"); + expect(properties).toHaveProperty("multipleOf"); + expect(properties).toHaveProperty("minProperties"); + expect(properties).toHaveProperty("maxProperties"); + expect(properties).toHaveProperty("default"); }); test("1.5: Meta-schema node type equals its own hash", async () => {