feat: support P1 leaf JSON Schema constraints (#82) #84

Merged
xingyue merged 1 commits from feat/82-schema-p1-leaf-constraints into main 2026-06-01 01:26:51 +00:00
4 changed files with 192 additions and 46 deletions
@@ -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!"`;
+11
View File
@@ -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;
+101
View File
@@ -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);
+34
View File
@@ -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;
}