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 is contained in:
@@ -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!"`;
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user