diff --git a/README.md b/README.md index 17feccc..4756251 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ Published contract schemas are immutable interoperability boundaries. A catalog-listed starter type contains an inline snapshot of its starting schema instead of inheriting that boundary. Once installed, the type belongs to the collection: users can edit its fields and update the explicit contract mapping -without changing the published contract. +without changing the published contract. Packs authored with +`expand_local_refs: true` expand every non-recursive local JSON Schema reference +so nested fields remain directly editable. Recursive references remain explicit +because they cannot be finitely expanded. The featured `mdbase.runtime.standard` pack supplies the durable runtime 0.2 standard library: ten ordinary record contracts and canonical implementing @@ -51,6 +54,13 @@ npm run build npm run verify ``` +When authoring a starter from an existing inline schema, expand its local +references before adding it to a listed pack: + +```sh +npm run expand:type -- types/example/1.md types/example/2.md +``` + Verification checks the catalog schema, every resource digest, a transactional dry run, a real install, idempotent reinstallation, and the declared contract implementations. diff --git a/dist/artifacts/types/jscontact-card/3.md b/dist/artifacts/types/jscontact-card/3.md new file mode 100644 index 0000000..b44e1a5 --- /dev/null +++ b/dist/artifacts/types/jscontact-card/3.md @@ -0,0 +1,225 @@ +--- +kind: mdbase.type +name: jscontact-card +version: 3 +description: A fully expanded, editable Markdown-backed JSContact 2.0 core Card +match: + where: + /@type: Card + version: "2.0" +schema: + dialect: json-schema-2020-12 + value: + $schema: https://json-schema.org/draft/2020-12/schema + title: JSContact Card 2.0 core profile + description: A local starting shape for an IETF JSContact 2.0 Card. + type: object + required: + - "@type" + - version + properties: + "@type": + const: Card + version: + const: "2.0" + uid: + type: string + minLength: 1 + kind: + enum: + - individual + - group + - org + - location + - device + - application + name: + type: object + properties: + "@type": + const: Name + components: + type: array + minItems: 1 + contains: + type: object + required: + - kind + properties: + kind: + not: + const: separator + items: + type: object + required: + - kind + - value + properties: + "@type": + const: NameComponent + kind: + enum: + - credential + - generation + - given + - given2 + - separator + - surname + - surname2 + - title + value: + type: string + additionalProperties: false + full: + type: string + minLength: 1 + isOrdered: + type: boolean + defaultSeparator: + type: string + anyOf: + - required: + - components + - required: + - full + additionalProperties: false + emails: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + required: + - address + properties: + "@type": + const: EmailAddress + address: + type: string + format: email + contexts: + type: object + additionalProperties: + const: true + pref: + type: integer + minimum: 1 + label: + type: string + additionalProperties: false + phones: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + required: + - number + properties: + "@type": + const: Phone + number: + type: string + minLength: 1 + features: + type: object + additionalProperties: + const: true + contexts: + type: object + additionalProperties: + const: true + pref: + type: integer + minimum: 1 + label: + type: string + additionalProperties: false + organizations: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + properties: + "@type": + const: Organization + name: + type: string + minLength: 1 + units: + type: array + minItems: 1 + items: + type: object + required: + - name + properties: + "@type": + const: OrgUnit + name: + type: string + minLength: 1 + additionalProperties: false + sortAs: + type: string + contexts: + type: object + additionalProperties: + const: true + anyOf: + - required: + - name + - required: + - units + additionalProperties: false + notes: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + required: + - note + properties: + "@type": + const: Note + note: + type: string + created: + type: string + format: date-time + additionalProperties: false + additionalProperties: false +collection: + display: + name_field: /name/full +implements: + - contract: mdbase.jscontact.card + version: 2.0.0 + fields: + /@type: /@type + version: version + uid: uid + kind: kind + name: name + emails: emails + phones: phones + organizations: organizations + notes: notes +--- + +# JSContact Card + +This starter type begins as a fully expanded local copy of the JSContact core +profile. Nested fields are declared inline so the type editor can edit them +directly. Its fields belong to the collection and can be added, renamed, or +reshaped. Update the explicit contract mapping whenever a compatible field +moves. + +The separately installed contract schema remains the portable boundary. Local +fields that are not mapped into that boundary remain collection-specific. diff --git a/dist/catalog.json b/dist/catalog.json index 0606d58..2758817 100644 --- a/dist/catalog.json +++ b/dist/catalog.json @@ -193,11 +193,11 @@ "packs": [ { "id": "mdbase.jscontact", - "version": "2.0.1", + "version": "2.0.2", "name": "JSContact 2.0 starter", - "description": "The mdbase JSContact Card core contract and an editable Markdown implementation.", - "digest": "sha256:c277f278b20acbd0b182d88e2c3c018220c2808aa57f73fa2da1b413383ed21c", - "provision": "./packs/mdbase.jscontact/2.0.1.json", + "description": "The mdbase JSContact Card core contract and a fully expanded, editable Markdown implementation.", + "digest": "sha256:f6f5aa76850c10b83d42c0fffb7f76d3a67b531fb1de7bb5ae9a9b2c783687b0", + "provision": "./packs/mdbase.jscontact/2.0.2.json", "provides": [ { "id": "mdbase.jscontact.card", diff --git a/dist/packs/mdbase.jscontact/2.0.2.json b/dist/packs/mdbase.jscontact/2.0.2.json new file mode 100644 index 0000000..81459ab --- /dev/null +++ b/dist/packs/mdbase.jscontact/2.0.2.json @@ -0,0 +1,49 @@ +{ + "manifest": { + "kind": "mdbase.type-pack", + "id": "mdbase.jscontact", + "version": "2.0.2", + "name": "JSContact 2.0 starter", + "description": "The mdbase JSContact Card core contract and a fully expanded, editable Markdown implementation.", + "resources": [ + { + "kind": "schema", + "source": "schemas/mdbase.jscontact.card/2.0.0.schema.json", + "target": "schemas/mdbase.jscontact.card/2.0.0.schema.json", + "digest": "sha256:e2831b1203303075767aefd5e4892c81793d4b85c45db706ef189aa6124f8e26" + }, + { + "kind": "contract", + "source": "contracts/mdbase.jscontact.card/2.0.0.md", + "target": "_contracts/mdbase.jscontact.card/2.0.0.md", + "digest": "sha256:c56c5acbf7992155d6890294842a261fe80a933ec75a226ea875b97ba57dcaa2" + }, + { + "kind": "type", + "source": "types/jscontact-card/3.md", + "target": "_types/jscontact-card.md", + "digest": "sha256:881d5669053430b5183f57d51d8a3c41d53145ac158a638379163d5bb83c469d" + } + ] + }, + "resources": [ + { + "source": "schemas/mdbase.jscontact.card/2.0.0.schema.json", + "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://mdbase.dev/contracts/schemas/mdbase.jscontact.card/2.0.0.schema.json\",\n \"title\": \"JSContact Card 2.0 core profile\",\n \"description\": \"A strict mdbase core profile of an IETF JSContact 2.0 Card.\",\n \"type\": \"object\",\n \"required\": [\"@type\", \"version\"],\n \"properties\": {\n \"@type\": { \"const\": \"Card\" },\n \"version\": { \"const\": \"2.0\" },\n \"uid\": { \"type\": \"string\", \"minLength\": 1 },\n \"kind\": {\n \"enum\": [\"individual\", \"group\", \"org\", \"location\", \"device\", \"application\"]\n },\n \"name\": { \"$ref\": \"#/$defs/name\" },\n \"emails\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/id\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/emailAddress\" }\n },\n \"phones\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/id\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/phone\" }\n },\n \"organizations\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/id\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/organization\" }\n },\n \"notes\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/id\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/note\" }\n }\n },\n \"additionalProperties\": false,\n \"$defs\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"trueSet\": {\n \"type\": \"object\",\n \"additionalProperties\": { \"const\": true }\n },\n \"name\": {\n \"type\": \"object\",\n \"properties\": {\n \"@type\": { \"const\": \"Name\" },\n \"components\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"contains\": {\n \"type\": \"object\",\n \"required\": [\"kind\"],\n \"properties\": {\n \"kind\": { \"not\": { \"const\": \"separator\" } }\n }\n },\n \"items\": { \"$ref\": \"#/$defs/nameComponent\" }\n },\n \"full\": { \"type\": \"string\", \"minLength\": 1 },\n \"isOrdered\": { \"type\": \"boolean\" },\n \"defaultSeparator\": { \"type\": \"string\" }\n },\n \"anyOf\": [\n { \"required\": [\"components\"] },\n { \"required\": [\"full\"] }\n ],\n \"additionalProperties\": false\n },\n \"nameComponent\": {\n \"type\": \"object\",\n \"required\": [\"kind\", \"value\"],\n \"properties\": {\n \"@type\": { \"const\": \"NameComponent\" },\n \"kind\": {\n \"enum\": [\n \"credential\",\n \"generation\",\n \"given\",\n \"given2\",\n \"separator\",\n \"surname\",\n \"surname2\",\n \"title\"\n ]\n },\n \"value\": { \"type\": \"string\" }\n },\n \"additionalProperties\": false\n },\n \"emailAddress\": {\n \"type\": \"object\",\n \"required\": [\"address\"],\n \"properties\": {\n \"@type\": { \"const\": \"EmailAddress\" },\n \"address\": { \"type\": \"string\", \"format\": \"email\" },\n \"contexts\": { \"$ref\": \"#/$defs/trueSet\" },\n \"pref\": { \"type\": \"integer\", \"minimum\": 1 },\n \"label\": { \"type\": \"string\" }\n },\n \"additionalProperties\": false\n },\n \"phone\": {\n \"type\": \"object\",\n \"required\": [\"number\"],\n \"properties\": {\n \"@type\": { \"const\": \"Phone\" },\n \"number\": { \"type\": \"string\", \"minLength\": 1 },\n \"features\": { \"$ref\": \"#/$defs/trueSet\" },\n \"contexts\": { \"$ref\": \"#/$defs/trueSet\" },\n \"pref\": { \"type\": \"integer\", \"minimum\": 1 },\n \"label\": { \"type\": \"string\" }\n },\n \"additionalProperties\": false\n },\n \"organization\": {\n \"type\": \"object\",\n \"properties\": {\n \"@type\": { \"const\": \"Organization\" },\n \"name\": { \"type\": \"string\", \"minLength\": 1 },\n \"units\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"$ref\": \"#/$defs/orgUnit\" }\n },\n \"sortAs\": { \"type\": \"string\" },\n \"contexts\": { \"$ref\": \"#/$defs/trueSet\" }\n },\n \"anyOf\": [\n { \"required\": [\"name\"] },\n { \"required\": [\"units\"] }\n ],\n \"additionalProperties\": false\n },\n \"orgUnit\": {\n \"type\": \"object\",\n \"required\": [\"name\"],\n \"properties\": {\n \"@type\": { \"const\": \"OrgUnit\" },\n \"name\": { \"type\": \"string\", \"minLength\": 1 }\n },\n \"additionalProperties\": false\n },\n \"note\": {\n \"type\": \"object\",\n \"required\": [\"note\"],\n \"properties\": {\n \"@type\": { \"const\": \"Note\" },\n \"note\": { \"type\": \"string\" },\n \"created\": { \"type\": \"string\", \"format\": \"date-time\" }\n },\n \"additionalProperties\": false\n }\n }\n}\n\n" + }, + { + "source": "contracts/mdbase.jscontact.card/2.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: mdbase.jscontact.card\nversion: 2.0.0\nname: JSContact Card 2.0 core\ndescription: A strict, portable core profile of an IETF JSContact 2.0 Card.\nrecord_schema:\n dialect: json-schema-2020-12\n ref: ../../schemas/mdbase.jscontact.card/2.0.0.schema.json\nx-standard:\n name: JSContact\n version: \"2.0\"\n scope: Strict core profile covering the Card envelope, names, email addresses, phones, organizations, and notes.\n references:\n - https://www.rfc-editor.org/rfc/rfc9553.html\n - https://www.rfc-editor.org/rfc/rfc9982.html\n---\n\n# JSContact Card 2.0 core\n\nThis contract provides a deliberately bounded JSContact Card representation\nfor interoperable contact records. It follows JSContact 2.0, including the\noptional `uid` introduced by RFC 9982.\n\nThe profile accepts a strict subset of JSContact properties. It is not an\nofficial IETF JSON Schema and does not claim to validate every JSContact\nextension or conditional semantic rule.\n\n" + }, + { + "source": "types/jscontact-card/3.md", + "document": "---\nkind: mdbase.type\nname: jscontact-card\nversion: 3\ndescription: A fully expanded, editable Markdown-backed JSContact 2.0 core Card\nmatch:\n where:\n /@type: Card\n version: \"2.0\"\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: JSContact Card 2.0 core profile\n description: A local starting shape for an IETF JSContact 2.0 Card.\n type: object\n required:\n - \"@type\"\n - version\n properties:\n \"@type\":\n const: Card\n version:\n const: \"2.0\"\n uid:\n type: string\n minLength: 1\n kind:\n enum:\n - individual\n - group\n - org\n - location\n - device\n - application\n name:\n type: object\n properties:\n \"@type\":\n const: Name\n components:\n type: array\n minItems: 1\n contains:\n type: object\n required:\n - kind\n properties:\n kind:\n not:\n const: separator\n items:\n type: object\n required:\n - kind\n - value\n properties:\n \"@type\":\n const: NameComponent\n kind:\n enum:\n - credential\n - generation\n - given\n - given2\n - separator\n - surname\n - surname2\n - title\n value:\n type: string\n additionalProperties: false\n full:\n type: string\n minLength: 1\n isOrdered:\n type: boolean\n defaultSeparator:\n type: string\n anyOf:\n - required:\n - components\n - required:\n - full\n additionalProperties: false\n emails:\n type: object\n propertyNames:\n type: string\n minLength: 1\n additionalProperties:\n type: object\n required:\n - address\n properties:\n \"@type\":\n const: EmailAddress\n address:\n type: string\n format: email\n contexts:\n type: object\n additionalProperties:\n const: true\n pref:\n type: integer\n minimum: 1\n label:\n type: string\n additionalProperties: false\n phones:\n type: object\n propertyNames:\n type: string\n minLength: 1\n additionalProperties:\n type: object\n required:\n - number\n properties:\n \"@type\":\n const: Phone\n number:\n type: string\n minLength: 1\n features:\n type: object\n additionalProperties:\n const: true\n contexts:\n type: object\n additionalProperties:\n const: true\n pref:\n type: integer\n minimum: 1\n label:\n type: string\n additionalProperties: false\n organizations:\n type: object\n propertyNames:\n type: string\n minLength: 1\n additionalProperties:\n type: object\n properties:\n \"@type\":\n const: Organization\n name:\n type: string\n minLength: 1\n units:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - name\n properties:\n \"@type\":\n const: OrgUnit\n name:\n type: string\n minLength: 1\n additionalProperties: false\n sortAs:\n type: string\n contexts:\n type: object\n additionalProperties:\n const: true\n anyOf:\n - required:\n - name\n - required:\n - units\n additionalProperties: false\n notes:\n type: object\n propertyNames:\n type: string\n minLength: 1\n additionalProperties:\n type: object\n required:\n - note\n properties:\n \"@type\":\n const: Note\n note:\n type: string\n created:\n type: string\n format: date-time\n additionalProperties: false\n additionalProperties: false\ncollection:\n display:\n name_field: /name/full\nimplements:\n - contract: mdbase.jscontact.card\n version: 2.0.0\n fields:\n /@type: /@type\n version: version\n uid: uid\n kind: kind\n name: name\n emails: emails\n phones: phones\n organizations: organizations\n notes: notes\n---\n\n# JSContact Card\n\nThis starter type begins as a fully expanded local copy of the JSContact core\nprofile. Nested fields are declared inline so the type editor can edit them\ndirectly. Its fields belong to the collection and can be added, renamed, or\nreshaped. Update the explicit contract mapping whenever a compatible field\nmoves.\n\nThe separately installed contract schema remains the portable boundary. Local\nfields that are not mapped into that boundary remain collection-specific.\n" + } + ], + "provides": [ + { + "id": "mdbase.jscontact.card", + "version": "2.0.0" + } + ] +} diff --git a/package.json b/package.json index 149660f..352788c 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "scripts": { "sync:runtime": "node scripts/sync-runtime-pack.mjs", "build": "node scripts/build.mjs", + "expand:type": "node scripts/expand-type.mjs", "verify": "node scripts/verify.mjs", - "test": "npm run build && npm run verify" + "test": "node --test scripts/schema-expansion.test.mjs && npm run build && npm run verify" }, "dependencies": { "ajv": "^8.20.0", diff --git a/packs/mdbase.jscontact/2.0.1.pack.yaml b/packs/mdbase.jscontact/2.0.1.pack.yaml index 2d1f31a..0abe0e3 100644 --- a/packs/mdbase.jscontact/2.0.1.pack.yaml +++ b/packs/mdbase.jscontact/2.0.1.pack.yaml @@ -4,6 +4,7 @@ version: 2.0.1 name: JSContact 2.0 starter description: The mdbase JSContact Card core contract and an editable Markdown implementation. featured: true +catalog: false provides: - id: mdbase.jscontact.card version: 2.0.0 diff --git a/packs/mdbase.jscontact/2.0.2.pack.yaml b/packs/mdbase.jscontact/2.0.2.pack.yaml new file mode 100644 index 0000000..197eced --- /dev/null +++ b/packs/mdbase.jscontact/2.0.2.pack.yaml @@ -0,0 +1,20 @@ +kind: mdbase.catalog-pack +id: mdbase.jscontact +version: 2.0.2 +name: JSContact 2.0 starter +description: The mdbase JSContact Card core contract and a fully expanded, editable Markdown implementation. +featured: true +expand_local_refs: true +provides: + - id: mdbase.jscontact.card + version: 2.0.0 +resources: + - kind: schema + source: schemas/mdbase.jscontact.card/2.0.0.schema.json + target: schemas/mdbase.jscontact.card/2.0.0.schema.json + - kind: contract + source: contracts/mdbase.jscontact.card/2.0.0.md + target: _contracts/mdbase.jscontact.card/2.0.0.md + - kind: type + source: types/jscontact-card/3.md + target: _types/jscontact-card.md diff --git a/scripts/build.mjs b/scripts/build.mjs index d2029de..28fa63f 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -11,6 +11,7 @@ import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import matter from "gray-matter"; import { parse } from "yaml"; +import { expandLocalReferences } from "./schema-expansion.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const dist = join(root, "dist"); @@ -70,7 +71,7 @@ for (const packFile of packFiles) { registerContract(resource.source, document, resourceDigest); } if (resource.kind === "type" && definition.catalog !== false) { - validateEditableType(resource.source, document); + validateEditableType(resource.source, document, definition.expand_local_refs === true); } } @@ -149,7 +150,7 @@ function registerContract(source, document, resourceDigest) { contracts.set(identity, entry); } -function validateEditableType(source, document) { +function validateEditableType(source, document, requireExpandedReferences) { const frontmatter = matter(document).data; if (frontmatter.kind !== "mdbase.type") { fail(`Type resource ${source} is not an mdbase.type document.`); @@ -166,6 +167,15 @@ function validateEditableType(source, document) { if (frontmatter.schema.ref !== undefined) { fail(`Catalog type resource ${source} must not inherit a referenced schema.`); } + if (requireExpandedReferences) { + const expansion = expandLocalReferences(frontmatter.schema.value); + if (expansion.expandedCount > 0) { + fail( + `Catalog type resource ${source} contains ${expansion.expandedCount} expandable local ` + + `${expansion.expandedCount === 1 ? "reference" : "references"}; run npm run expand:type.`, + ); + } + } } function validatePackDefinition(value, label) { @@ -181,6 +191,12 @@ function validatePackDefinition(value, label) { if (value.catalog !== undefined && typeof value.catalog !== "boolean") { fail(`${label} catalog must be a boolean.`); } + if ( + value.expand_local_refs !== undefined + && typeof value.expand_local_refs !== "boolean" + ) { + fail(`${label} expand_local_refs must be a boolean.`); + } if (!Array.isArray(value.provides) || value.provides.length === 0) { fail(`${label} must provide at least one contract.`); } diff --git a/scripts/expand-type.mjs b/scripts/expand-type.mjs new file mode 100644 index 0000000..a7f2c5b --- /dev/null +++ b/scripts/expand-type.mjs @@ -0,0 +1,32 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { parseDocument } from "yaml"; +import { expandLocalReferences } from "./schema-expansion.mjs"; + +const [, , input, output] = process.argv; +if (!input || !output) { + throw new Error("Usage: node scripts/expand-type.mjs "); +} + +const source = await readFile(input, "utf8"); +const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n[\s\S]*)$/); +if (!match) throw new Error(`${input} is not a frontmatter document.`); + +const document = parseDocument(match[1]); +const schema = document.getIn(["schema", "value"], true)?.toJSON(); +if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error(`${input} does not contain schema.value.`); +} + +const expanded = expandLocalReferences(schema); +if (expanded.expandedCount === 0) { + throw new Error(`${input} does not contain expandable local references.`); +} +document.setIn(["schema", "value"], expanded.schema); +await writeFile(output, `---\n${document.toString()}---${match[2]}`); + +console.log( + `Expanded ${expanded.expandedCount} local references into ${output}` + + (expanded.recursiveReferences.length + ? `; retained ${expanded.recursiveReferences.length} recursive references` + : ""), +); diff --git a/scripts/schema-expansion.mjs b/scripts/schema-expansion.mjs new file mode 100644 index 0000000..2490b0b --- /dev/null +++ b/scripts/schema-expansion.mjs @@ -0,0 +1,126 @@ +/** + * Expand every acyclic, document-local JSON Schema reference. + * + * References whose target graph reaches itself remain references. They cannot + * be finitely expanded and continue to rely on their original definitions. + */ +export function expandLocalReferences(schema) { + const root = clone(schema); + let expandedCount = 0; + const recursiveReferences = new Set(); + + const visit = (value) => { + if (Array.isArray(value)) return value.map(visit); + if (!record(value)) return value; + + const reference = typeof value.$ref === "string" ? value.$ref : undefined; + if (localPointer(reference)) { + if (referenceIsRecursive(root, reference)) { + recursiveReferences.add(reference); + return Object.fromEntries( + Object.entries(value).map(([key, candidate]) => [ + key, + key === "$ref" ? candidate : visit(candidate), + ]), + ); + } + const target = resolveLocalPointer(root, reference); + const siblings = Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "$ref") + .map(([key, candidate]) => [key, visit(candidate)]), + ); + expandedCount += 1; + const expandedTarget = visit(clone(target)); + return Object.keys(siblings).length + ? { allOf: [expandedTarget, siblings] } + : expandedTarget; + } + + return Object.fromEntries( + Object.entries(value).map(([key, candidate]) => [ + key, + key === "$defs" ? candidate : visit(candidate), + ]), + ); + }; + + const expanded = visit(root); + if (appliedLocalReferences(expanded).length === 0 && record(expanded)) { + delete expanded.$defs; + } + return { + schema: expanded, + expandedCount, + recursiveReferences: [...recursiveReferences].sort(), + }; +} + +export function localReferences(value) { + const references = []; + const visit = (candidate) => { + if (Array.isArray(candidate)) { + candidate.forEach(visit); + return; + } + if (!record(candidate)) return; + if (localPointer(candidate.$ref)) references.push(candidate.$ref); + Object.values(candidate).forEach(visit); + }; + visit(value); + return [...new Set(references)].sort(); +} + +function appliedLocalReferences(value) { + const references = []; + const visit = (candidate) => { + if (Array.isArray(candidate)) { + candidate.forEach(visit); + return; + } + if (!record(candidate)) return; + if (localPointer(candidate.$ref)) references.push(candidate.$ref); + Object.entries(candidate).forEach(([key, nested]) => { + if (key !== "$defs") visit(nested); + }); + }; + visit(value); + return [...new Set(references)].sort(); +} + +function referenceIsRecursive(root, initialReference) { + const visit = (reference, path) => { + if (path.has(reference)) return true; + const nextPath = new Set(path).add(reference); + const target = resolveLocalPointer(root, reference); + return localReferences(target).some((nested) => visit(nested, nextPath)); + }; + return visit(initialReference, new Set()); +} + +function resolveLocalPointer(root, reference) { + let current = root; + for (const encoded of reference.slice(2).split("/")) { + const segment = encoded.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!record(current) && !Array.isArray(current)) { + throw new Error(`Local schema reference ${reference} does not resolve.`); + } + current = current[segment]; + if (current === undefined) { + throw new Error(`Local schema reference ${reference} does not resolve.`); + } + } + return current; +} + +function localPointer(value) { + return typeof value === "string" && value.startsWith("#/"); +} + +function clone(value) { + return structuredClone(value); +} + +function record(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/schema-expansion.test.mjs b/scripts/schema-expansion.test.mjs new file mode 100644 index 0000000..d68b819 --- /dev/null +++ b/scripts/schema-expansion.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { expandLocalReferences, localReferences } from "./schema-expansion.mjs"; + +test("expands repeated acyclic local references and removes definitions", () => { + const source = { + type: "object", + properties: { + home: { $ref: "#/$defs/address" }, + work: { $ref: "#/$defs/address" }, + }, + $defs: { + address: { + type: "object", + properties: { city: { type: "string" } }, + }, + }, + }; + + const result = expandLocalReferences(source); + + assert.equal(result.expandedCount, 2); + assert.deepEqual(result.recursiveReferences, []); + assert.deepEqual(result.schema.properties.home, { + type: "object", + properties: { city: { type: "string" } }, + }); + assert.deepEqual(result.schema.properties.work, result.schema.properties.home); + assert.equal("$defs" in result.schema, false); + assert.deepEqual(localReferences(result.schema), []); +}); + +test("retains references whose definition graph is recursive", () => { + const source = { + $ref: "#/$defs/node", + $defs: { + node: { + type: "object", + properties: { + children: { + type: "array", + items: { $ref: "#/$defs/node" }, + }, + }, + }, + }, + }; + + const result = expandLocalReferences(source); + + assert.equal(result.expandedCount, 0); + assert.deepEqual(result.recursiveReferences, ["#/$defs/node"]); + assert.deepEqual(result.schema, source); +}); + +test("preserves ref sibling semantics with allOf", () => { + const source = { + type: "object", + properties: { + label: { + $ref: "#/$defs/text", + minLength: 3, + }, + }, + $defs: { + text: { type: "string", maxLength: 20 }, + }, + }; + + const result = expandLocalReferences(source); + + assert.deepEqual(result.schema.properties.label, { + allOf: [ + { type: "string", maxLength: 20 }, + { minLength: 3 }, + ], + }); +}); + +test("removes definitions that only reference one another after use-site expansion", () => { + const source = { + type: "object", + properties: { + profile: { $ref: "#/$defs/profile" }, + }, + $defs: { + profile: { + type: "object", + properties: { + name: { $ref: "#/$defs/name" }, + }, + }, + name: { type: "string" }, + }, + }; + + const result = expandLocalReferences(source); + + assert.deepEqual(result.schema, { + type: "object", + properties: { + profile: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }); +}); + +test("rejects unresolved local references", () => { + assert.throws( + () => expandLocalReferences({ $ref: "#/$defs/missing" }), + /does not resolve/, + ); +}); diff --git a/types/jscontact-card/3.md b/types/jscontact-card/3.md new file mode 100644 index 0000000..b44e1a5 --- /dev/null +++ b/types/jscontact-card/3.md @@ -0,0 +1,225 @@ +--- +kind: mdbase.type +name: jscontact-card +version: 3 +description: A fully expanded, editable Markdown-backed JSContact 2.0 core Card +match: + where: + /@type: Card + version: "2.0" +schema: + dialect: json-schema-2020-12 + value: + $schema: https://json-schema.org/draft/2020-12/schema + title: JSContact Card 2.0 core profile + description: A local starting shape for an IETF JSContact 2.0 Card. + type: object + required: + - "@type" + - version + properties: + "@type": + const: Card + version: + const: "2.0" + uid: + type: string + minLength: 1 + kind: + enum: + - individual + - group + - org + - location + - device + - application + name: + type: object + properties: + "@type": + const: Name + components: + type: array + minItems: 1 + contains: + type: object + required: + - kind + properties: + kind: + not: + const: separator + items: + type: object + required: + - kind + - value + properties: + "@type": + const: NameComponent + kind: + enum: + - credential + - generation + - given + - given2 + - separator + - surname + - surname2 + - title + value: + type: string + additionalProperties: false + full: + type: string + minLength: 1 + isOrdered: + type: boolean + defaultSeparator: + type: string + anyOf: + - required: + - components + - required: + - full + additionalProperties: false + emails: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + required: + - address + properties: + "@type": + const: EmailAddress + address: + type: string + format: email + contexts: + type: object + additionalProperties: + const: true + pref: + type: integer + minimum: 1 + label: + type: string + additionalProperties: false + phones: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + required: + - number + properties: + "@type": + const: Phone + number: + type: string + minLength: 1 + features: + type: object + additionalProperties: + const: true + contexts: + type: object + additionalProperties: + const: true + pref: + type: integer + minimum: 1 + label: + type: string + additionalProperties: false + organizations: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + properties: + "@type": + const: Organization + name: + type: string + minLength: 1 + units: + type: array + minItems: 1 + items: + type: object + required: + - name + properties: + "@type": + const: OrgUnit + name: + type: string + minLength: 1 + additionalProperties: false + sortAs: + type: string + contexts: + type: object + additionalProperties: + const: true + anyOf: + - required: + - name + - required: + - units + additionalProperties: false + notes: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + type: object + required: + - note + properties: + "@type": + const: Note + note: + type: string + created: + type: string + format: date-time + additionalProperties: false + additionalProperties: false +collection: + display: + name_field: /name/full +implements: + - contract: mdbase.jscontact.card + version: 2.0.0 + fields: + /@type: /@type + version: version + uid: uid + kind: kind + name: name + emails: emails + phones: phones + organizations: organizations + notes: notes +--- + +# JSContact Card + +This starter type begins as a fully expanded local copy of the JSContact core +profile. Nested fields are declared inline so the type editor can edit them +directly. Its fields belong to the collection and can be added, renamed, or +reshaped. Update the explicit contract mapping whenever a compatible field +moves. + +The separately installed contract schema remains the portable boundary. Local +fields that are not mapped into that boundary remain collection-specific.