Merge pull request 'feat: P2 JSON Schema support — allOf, if/then/else, patternProperties, prefixItems (#82)' (#87) from feat/82-schema-p2 into main

This commit was merged in pull request #87.
This commit is contained in:
2026-06-01 02:15:35 +00:00
6 changed files with 352 additions and 54 deletions
@@ -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!"`;
+1 -1
View File
@@ -219,7 +219,7 @@ async function cmdPut(args: string[]): Promise<void> {
try {
const hash = await putSchema(store, payload as Record<string, unknown>);
out(await wrapEnvelope(store, "@output/put", hash));
} catch (e) {
} catch (_e) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
+22
View File
@@ -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;
+162
View File
@@ -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);
});
});
+109 -6
View File
@@ -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<string, unknown>)) {
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<string, JSONSchema>;
const obj = value as Record<string, unknown>;
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;
@@ -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 () => {