From 70af05adf5424fb838baa8a4ef55f29ab20a4617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 01:14:02 +0000 Subject: [PATCH] feat: support P1 leaf JSON Schema constraints (#82) Add 10 new schema keywords to whitelist + meta-schema: - numeric: minimum, maximum, exclusiveMinimum, exclusiveMaximum - string: minLength, maxLength, pattern - array: minItems, maxItems, uniqueItems These are pure leaf constraints with no collectRefs() impact. 5 new tests covering validation, rejection, and nested usage. Refs #82 --- .../src/__snapshots__/e2e.test.ts.snap | 92 ++++++++-------- packages/json-cas/src/bootstrap.ts | 11 ++ packages/json-cas/src/schema.test.ts | 101 ++++++++++++++++++ packages/json-cas/src/schema.ts | 34 ++++++ 4 files changed, 192 insertions(+), 46 deletions(-) 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 7245af2..dbb7672 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": "ASE7K6A0HG8W9", + "type": "2NHHHE9NR0FVT", "value": { "payload": { "age": 30, "name": "Alice", }, - "type": "7XX5H51CVD9H0", + "type": "3K9ETAPGFPE32", }, } `; exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = ` { - "type": "8E2M8H30BHXS8", + "type": "BZ3TV0PTATA52", "value": "ok", } `; exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = ` "{ - "type": "4N5REDA48XYJP", + "type": "18HZGJY02WN1K", "value": [] }" `; exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = ` "{ - "type": "7124NEATTWYYY", + "type": "9BVX9SHACFC5J", "value": [ - "ERARPP19YJT05" + "A0QKG4ERMXSFG" ] }" `; @@ -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": "AYHQD2YA9G667", + "type": "EMX6HW4CECBGK", "value": { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": {}, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, } `; exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = ` { - "type": "BVW2SAJ8606EZ", + "type": "F68GWJAT9H6HP", "value": { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": {}, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, } `; exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` { - "type": "3BY1S4RKNMR0P", + "type": "A4TV1FVBBNG5M", "value": [ { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": {}, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, ], } @@ -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": "3BY1S4RKNMR0P", + "type": "A4TV1FVBBNG5M", "value": [ { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": {}, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, ], } @@ -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": "AYHQD2YA9G667", + "type": "EMX6HW4CECBGK", "value": { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": {}, - "value": "F68P1BZ46YDXM", + "value": "FNDM9C6P23238", }, } `; exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = ` { - "type": "BKMJ3DJHTS6VB", + "type": "92JS8RXGKDKC2", "value": { "labels": [ "important", ], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": { "env": "prod", }, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, } `; exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = ` { - "type": "3BY1S4RKNMR0P", + "type": "A4TV1FVBBNG5M", "value": [ { "labels": [ "important", ], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": { "env": "prod", }, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, ], } @@ -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": "3BY1S4RKNMR0P", + "type": "A4TV1FVBBNG5M", "value": [ { "labels": [ "important", ], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": { "env": "prod", }, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, ], } @@ -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": "BKMJ3DJHTS6VB", + "type": "92JS8RXGKDKC2", "value": { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": { "env": "prod", }, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, } `; exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = ` { - "type": "ASWN8JEGAG7AP", + "type": "00YH4RTNVWTRB", "value": [ { "labels": [], "name": "myapp/config", - "schema": "7XX5H51CVD9H0", + "schema": "3K9ETAPGFPE32", "tags": { "env": "prod", }, - "value": "ERARPP19YJT05", + "value": "A0QKG4ERMXSFG", }, ], } `; -exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`; +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 4: Template System 4.1 template set registers template 1`] = ` { - "type": "9YJZ09DDAYAWR", + "type": "DTNQZ90QQHA6D", "value": { - "contentHash": "FC8WACA792B6F", - "schemaHash": "7XX5H51CVD9H0", + "contentHash": "DBBPX5JJ74SWZ", + "schemaHash": "3K9ETAPGFPE32", }, } `; exports[`Phase 4: Template System 4.2 template get returns template text 1`] = ` { - "type": "FJG23DR9456WA", + "type": "8KMGVC6TG12TS", "value": "Name: {{ payload.name }}, Age: {{ payload.age }}", } `; exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = ` { - "type": "3JB2JHXHZG2Z1", + "type": "6RZCT3XX4J8BC", "value": [ { - "contentHash": "FC8WACA792B6F", - "schemaHash": "7XX5H51CVD9H0", + "contentHash": "DBBPX5JJ74SWZ", + "schemaHash": "3K9ETAPGFPE32", }, ], } @@ -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": "0PYGQE16XPM70", + "type": "DBHJH385HGVTM", "value": { "deleted": true, }, } `; -exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`; +exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 3K9ETAPGFPE32"`; exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`; diff --git a/packages/json-cas/src/bootstrap.ts b/packages/json-cas/src/bootstrap.ts index 8fbe5da..1e01dd4 100644 --- a/packages/json-cas/src/bootstrap.ts +++ b/packages/json-cas/src/bootstrap.ts @@ -60,6 +60,17 @@ const BOOTSTRAP_PAYLOAD = { enum: { type: "array" }, const: {}, description: { type: "string" }, + // P1 leaf constraints + minimum: { type: "number" }, + maximum: { type: "number" }, + exclusiveMinimum: { type: "number" }, + exclusiveMaximum: { type: "number" }, + minLength: { type: "number" }, + maxLength: { type: "number" }, + pattern: { type: "string" }, + minItems: { type: "number" }, + maxItems: { type: "number" }, + uniqueItems: { type: "boolean" }, }, } as const; diff --git a/packages/json-cas/src/schema.test.ts b/packages/json-cas/src/schema.test.ts index 59731db..465814c 100644 --- a/packages/json-cas/src/schema.test.ts +++ b/packages/json-cas/src/schema.test.ts @@ -388,6 +388,107 @@ describe("bootstrap meta-schema self-reference", () => { expect(dataNode.type).not.toBe(metaHash); }); + // ── P1 leaf constraints ────────────────────────────────────────────────── + + test("accepts schema with numeric constraints (minimum/maximum)", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "number", + minimum: 0, + maximum: 100, + exclusiveMinimum: -1, + exclusiveMaximum: 101, + }); + expect(hash).toHaveLength(13); + + // validate a conforming payload + const nodeHash = await store.put(hash, 42); + const node = store.get(nodeHash) as CasNode; + expect(validate(store, node)).toBe(true); + + // validate a non-conforming payload + const badHash = await store.put(hash, 200); + const badNode = store.get(badHash) as CasNode; + expect(validate(store, badNode)).toBe(false); + }); + + test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "string", + minLength: 1, + maxLength: 10, + pattern: "^[a-z]+$", + }); + expect(hash).toHaveLength(13); + + const goodHash = await store.put(hash, "hello"); + expect(validate(store, store.get(goodHash) as CasNode)).toBe(true); + + const badHash = await store.put(hash, "HELLO"); + expect(validate(store, store.get(badHash) as CasNode)).toBe(false); + }); + + test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "array", + items: { type: "number" }, + minItems: 1, + maxItems: 3, + uniqueItems: true, + }); + expect(hash).toHaveLength(13); + + const goodHash = await store.put(hash, [1, 2, 3]); + expect(validate(store, store.get(goodHash) as CasNode)).toBe(true); + + const tooMany = await store.put(hash, [1, 2, 3, 4]); + expect(validate(store, store.get(tooMany) as CasNode)).toBe(false); + + const dupes = await store.put(hash, [1, 1]); + expect(validate(store, store.get(dupes) as CasNode)).toBe(false); + }); + + test("rejects schema with wrong constraint types", async () => { + const store = createMemoryStore(); + await expect( + putSchema(store, { type: "number", minimum: "zero" } as never), + ).rejects.toThrow(); + await expect( + putSchema(store, { type: "string", maxLength: true } as never), + ).rejects.toThrow(); + await expect( + putSchema(store, { type: "array", uniqueItems: 1 } as never), + ).rejects.toThrow(); + }); + + test("accepts schema with nested property constraints", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 50 }, + age: { type: "number", minimum: 0, maximum: 150 }, + scores: { + type: "array", + items: { type: "number" }, + minItems: 1, + uniqueItems: true, + }, + }, + required: ["name"], + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, { + name: "Alice", + age: 30, + scores: [95, 87], + }); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + }); + test("bootstrap is idempotent across putSchema calls", async () => { const store = createMemoryStore(); const builtinSchemas = await bootstrap(store); diff --git a/packages/json-cas/src/schema.ts b/packages/json-cas/src/schema.ts index 19a57bc..cf30f02 100644 --- a/packages/json-cas/src/schema.ts +++ b/packages/json-cas/src/schema.ts @@ -35,6 +35,17 @@ const ALLOWED_SCHEMA_KEYS = new Set([ "enum", "const", "description", + // P1 leaf constraints (no collectRefs impact) + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minLength", + "maxLength", + "pattern", + "minItems", + "maxItems", + "uniqueItems", ]); const JSON_SCHEMA_TYPES = new Set([ @@ -126,6 +137,29 @@ function isValidSchema(value: unknown): boolean { if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false; } + // P1 leaf constraints — type checks only + if ("minimum" in schema && typeof schema.minimum !== "number") return false; + if ("maximum" in schema && typeof schema.maximum !== "number") return false; + if ( + "exclusiveMinimum" in schema && + typeof schema.exclusiveMinimum !== "number" + ) + return false; + if ( + "exclusiveMaximum" in schema && + typeof schema.exclusiveMaximum !== "number" + ) + return false; + if ("minLength" in schema && typeof schema.minLength !== "number") + return false; + if ("maxLength" in schema && typeof schema.maxLength !== "number") + return false; + if ("pattern" in schema && typeof schema.pattern !== "string") return false; + if ("minItems" in schema && typeof schema.minItems !== "number") return false; + if ("maxItems" in schema && typeof schema.maxItems !== "number") return false; + if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean") + return false; + return true; } -- 2.43.0