feat: P3 JSON Schema support — not, contains, propertyNames, metadata (#82) #88

Merged
xingyue merged 1 commits from feat/82-schema-p3 into main 2026-06-01 02:34:08 +00:00
5 changed files with 194 additions and 48 deletions
@@ -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!"`;
+10
View File
@@ -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;
+87
View File
@@ -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);
});
});
+41 -1
View File
@@ -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;
}
@@ -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 () => {