From ca4fe8c3acd3adc924cab8c92bf6be411c72bba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 1 Jun 2026 02:31:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20P3=20JSON=20Schema=20support=20?= =?UTF-8?q?=E2=80=94=20not,=20contains,=20propertyNames,=20metadata=20(#82?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 8 new schema keywords: Combinators (with collectRefs support): - not: negation schema - contains: array must contain matching item Constraints: - propertyNames: constrain object key names Metadata (no collectRefs impact): - examples, readOnly, writeOnly, deprecated, $comment Total supported keywords: 40 (of ~45 commonly used). Only $ref/$defs/$id/$schema remain unsupported (P4, deferred). 516 tests, 0 fail. Closes #82 --- .../src/__snapshots__/e2e.test.ts.snap | 92 +++++++++---------- packages/json-cas/src/bootstrap.ts | 10 ++ packages/json-cas/src/schema.test.ts | 87 ++++++++++++++++++ packages/json-cas/src/schema.ts | 42 ++++++++- .../json-cas/tests/schema-validation.test.ts | 11 ++- 5 files changed, 194 insertions(+), 48 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 b3ebb48..64b4b6f 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": "CRK52TZT2ZND7", + "type": "CE40D09H75NFZ", "value": { "payload": { "age": 30, "name": "Alice", }, - "type": "5J4TDW3H4J427", + "type": "8WAZV39SD724T", }, } `; exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = ` { - "type": "0RJTSZR6K608G", + "type": "BZ31JDDWX2AWH", "value": "ok", } `; exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = ` "{ - "type": "DGB04ECT2M49Q", + "type": "DG8SAB75PV9P7", "value": [] }" `; exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = ` "{ - "type": "EQ015GFTBKQJA", + "type": "EVHG7Q7FK83H0", "value": [ - "078KT0M8N554S" + "6KZ930XYK2MHB" ] }" `; @@ -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": "E88C2CKY90ERT", + "type": "5KVHSESSX7N96", "value": { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": {}, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, } `; exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = ` { - "type": "0GVNZDR5AC35Y", + "type": "7D4R2MJ1EYF08", "value": { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": {}, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, } `; exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` { - "type": "3XAAW976C1KT8", + "type": "E8158M25YNR9M", "value": [ { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": {}, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, ], } @@ -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": "3XAAW976C1KT8", + "type": "E8158M25YNR9M", "value": [ { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": {}, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, ], } @@ -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": "E88C2CKY90ERT", + "type": "5KVHSESSX7N96", "value": { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": {}, - "value": "0S3E6H68VK328", + "value": "AE80669JYG4K2", }, } `; exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = ` { - "type": "92GB8QS47S7HS", + "type": "2WX4XRYSACRWR", "value": { "labels": [ "important", ], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": { "env": "prod", }, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, } `; exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = ` { - "type": "3XAAW976C1KT8", + "type": "E8158M25YNR9M", "value": [ { "labels": [ "important", ], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": { "env": "prod", }, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, ], } @@ -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": "3XAAW976C1KT8", + "type": "E8158M25YNR9M", "value": [ { "labels": [ "important", ], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": { "env": "prod", }, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, ], } @@ -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": "92GB8QS47S7HS", + "type": "2WX4XRYSACRWR", "value": { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": { "env": "prod", }, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, } `; exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = ` { - "type": "EV9W9HDVK7WX2", + "type": "F89626QEK09YY", "value": [ { "labels": [], "name": "myapp/config", - "schema": "5J4TDW3H4J427", + "schema": "8WAZV39SD724T", "tags": { "env": "prod", }, - "value": "078KT0M8N554S", + "value": "6KZ930XYK2MHB", }, ], } `; -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 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=8WAZV39SD724T"`; exports[`Phase 4: Template System 4.1 template set registers template 1`] = ` { - "type": "FTZ3B2BN1H3SA", + "type": "A8RGY9B1JSCDJ", "value": { - "contentHash": "0MNS832R7RZSV", - "schemaHash": "5J4TDW3H4J427", + "contentHash": "0359SHMP7VBFD", + "schemaHash": "8WAZV39SD724T", }, } `; exports[`Phase 4: Template System 4.2 template get returns template text 1`] = ` { - "type": "EZ034E7H0JWFV", + "type": "64FP3TWKK69YH", "value": "Name: {{ payload.name }}, Age: {{ payload.age }}", } `; exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = ` { - "type": "DCVZJFMSKBGJV", + "type": "BE20H482GBNM3", "value": [ { - "contentHash": "0MNS832R7RZSV", - "schemaHash": "5J4TDW3H4J427", + "contentHash": "0359SHMP7VBFD", + "schemaHash": "8WAZV39SD724T", }, ], } @@ -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": "3RKA4H2KVF0TF", + "type": "0FXB2GS8Y9R1R", "value": { "deleted": true, }, } `; -exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 5J4TDW3H4J427"`; +exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`; 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 63822bc..53218da 100644 --- a/packages/json-cas/src/bootstrap.ts +++ b/packages/json-cas/src/bootstrap.ts @@ -93,6 +93,16 @@ const BOOTSTRAP_PAYLOAD = { minProperties: { type: "number" }, maxProperties: { type: "number" }, default: {}, + // P3 combinators + not: { type: "object", additionalProperties: false }, + contains: { type: "object", additionalProperties: false }, + propertyNames: { type: "object", additionalProperties: false }, + // P3 metadata + examples: { type: "array" }, + readOnly: { type: "boolean" }, + writeOnly: { type: "boolean" }, + deprecated: { type: "boolean" }, + $comment: { type: "string" }, }, } as const; diff --git a/packages/json-cas/src/schema.test.ts b/packages/json-cas/src/schema.test.ts index dd214ea..e2df4e0 100644 --- a/packages/json-cas/src/schema.test.ts +++ b/packages/json-cas/src/schema.test.ts @@ -663,4 +663,91 @@ describe("bootstrap meta-schema self-reference", () => { const refList = refs(store, node); expect(refList).toContain(targetHash); }); + + // ── P3 combinators, propertyNames, and metadata ────────────────────────── + + test("accepts schema with not", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + not: { type: "string" }, + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, 42); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + + const bad = await store.put(hash, "hello"); + expect(validate(store, store.get(bad) as CasNode)).toBe(false); + }); + + test("accepts schema with contains", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "array", + contains: { type: "number", minimum: 10 }, + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, [1, 2, 15]); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + + const bad = await store.put(hash, [1, 2, 3]); + expect(validate(store, store.get(bad) as CasNode)).toBe(false); + }); + + test("accepts schema with propertyNames", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "object", + propertyNames: { pattern: "^[a-z]+$" }, + }); + expect(hash).toHaveLength(13); + + const good = await store.put(hash, { foo: 1, bar: 2 }); + expect(validate(store, store.get(good) as CasNode)).toBe(true); + + const bad = await store.put(hash, { Foo: 1 }); + expect(validate(store, store.get(bad) as CasNode)).toBe(false); + }); + + test("accepts schema with metadata keywords", async () => { + const store = createMemoryStore(); + const hash = await putSchema(store, { + type: "string", + examples: ["hello", "world"], + readOnly: true, + deprecated: false, + $comment: "This is a test schema", + }); + expect(hash).toHaveLength(13); + }); + + test("rejects invalid P3 keyword types", async () => { + const store = createMemoryStore(); + await expect( + putSchema(store, { not: "not-object" } as never), + ).rejects.toThrow(); + await expect( + putSchema(store, { examples: "not-array" } as never), + ).rejects.toThrow(); + await expect( + putSchema(store, { readOnly: "yes" } as never), + ).rejects.toThrow(); + await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow(); + }); + + test("collectRefs traverses contains", async () => { + const store = createMemoryStore(); + const innerSchema = await putSchema(store, { type: "string" }); + const schema = await putSchema(store, { + type: "array", + contains: { type: "string", format: "cas_ref" }, + }); + + const targetHash = await store.put(innerSchema, "hello"); + const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]); + 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 924f46c..a86ac6d 100644 --- a/packages/json-cas/src/schema.ts +++ b/packages/json-cas/src/schema.ts @@ -58,6 +58,16 @@ const ALLOWED_SCHEMA_KEYS = new Set([ "minProperties", "maxProperties", "default", + // P3 combinators (need collectRefs) + "not", + "contains", + "propertyNames", + // P3 metadata (no collectRefs impact) + "examples", + "readOnly", + "writeOnly", + "deprecated", + "$comment", ]); const JSON_SCHEMA_TYPES = new Set([ @@ -210,6 +220,22 @@ function isValidSchema(value: unknown): boolean { return false; // "default" accepts any value — no type check needed + // P3 combinators — recursive sub-schema checks + if ("not" in schema && !isValidSchema(schema.not)) return false; + if ("contains" in schema && !isValidSchema(schema.contains)) return false; + if ("propertyNames" in schema && !isValidSchema(schema.propertyNames)) + return false; + + // P3 metadata — type checks only + if ("examples" in schema && !Array.isArray(schema.examples)) return false; + if ("readOnly" in schema && typeof schema.readOnly !== "boolean") + return false; + if ("writeOnly" in schema && typeof schema.writeOnly !== "boolean") + return false; + if ("deprecated" in schema && typeof schema.deprecated !== "boolean") + return false; + if ("$comment" in schema && typeof schema.$comment !== "string") return false; + return true; } @@ -268,7 +294,7 @@ export function validate(store: Store, node: CasNode): boolean { /** * Recursively collect values of all properties whose schema has format: 'cas_ref'. * Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals), - * items + prefixItems (arrays), properties (nested objects), + * not, contains, items + prefixItems (arrays), properties (nested objects), * additionalProperties (record refs), and patternProperties (regex-keyed refs). */ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] { @@ -328,6 +354,15 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] { result.push(...collectRefs(itemSchema, arr[i])); } } + + // P3: contains — sub-schema for array items + if (schema.contains && typeof schema.contains === "object") { + const containsSchema = schema.contains as JSONSchema; + for (const item of value as unknown[]) { + result.push(...collectRefs(containsSchema, item)); + } + } + return result; } @@ -369,6 +404,11 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] { } } + // P3: not — sub-schema applies to the same value + if (schema.not && typeof schema.not === "object") { + result.push(...collectRefs(schema.not as JSONSchema, value)); + } + return result; } diff --git a/packages/json-cas/tests/schema-validation.test.ts b/packages/json-cas/tests/schema-validation.test.ts index 30287f8..6afa235 100644 --- a/packages/json-cas/tests/schema-validation.test.ts +++ b/packages/json-cas/tests/schema-validation.test.ts @@ -72,7 +72,6 @@ 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("not"); // P2 keywords should be present expect(properties).toHaveProperty("allOf"); @@ -85,6 +84,16 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { expect(properties).toHaveProperty("minProperties"); expect(properties).toHaveProperty("maxProperties"); expect(properties).toHaveProperty("default"); + + // P3 keywords should be present + expect(properties).toHaveProperty("not"); + expect(properties).toHaveProperty("contains"); + expect(properties).toHaveProperty("propertyNames"); + expect(properties).toHaveProperty("examples"); + expect(properties).toHaveProperty("readOnly"); + expect(properties).toHaveProperty("writeOnly"); + expect(properties).toHaveProperty("deprecated"); + expect(properties).toHaveProperty("$comment"); }); test("1.5: Meta-schema node type equals its own hash", async () => {