From f735b40f6fa4b68ebd7cd04dfee4b2b4e532aa68 Mon Sep 17 00:00:00 2001 From: Justin Levine <20596508+jal-co@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:15:39 -0400 Subject: [PATCH] feat: canonical taxonomy v0 (categories, component types, aliases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7. - taxonomy/v0/taxonomy.json: versioned vocabulary — 12 categories (what a thing is for) and 65 component types (what it is, the shadcn-style vocabulary), plus alias and deprecation machinery. Discovery metadata only: taxonomy changes never touch a release's source content or digest. - schemas/v0/taxonomy.schema.json validates the taxonomy document itself (kebab ids, label/description limits, alias map shape). - modulora-item schema gains optional category, componentType, and tags (max 10, normalized kebab) — additive, so existing v0 artifacts stay valid; registries require category at publish time. - Validation gains a taxonomy resolution layer (resolve aliases → id must exist → not deprecated for new publishes) exercised by fixtures: canonical values, alias migration (forms-inputs → forms), unknown id, malformed casing, tag flooding, markup-in-tag. 27/27 tests pass. The web app's src/lib/taxonomy.ts is the same vocabulary; this document is now the canonical home. Signed-off-by: Justin Levine <20596508+jal-co@users.noreply.github.com> --- fixtures/invalid/item-category-not-kebab.json | 26 ++ fixtures/invalid/item-category-unknown.json | 26 ++ fixtures/invalid/item-too-many-tags.json | 38 +++ fixtures/malicious/item-tag-markup.json | 28 ++ fixtures/manifest.json | 29 +- fixtures/valid/item-category-alias.json | 26 ++ fixtures/valid/item-with-taxonomy.json | 31 ++ schemas/v0/modulora-item.schema.json | 212 ++++++++++-- schemas/v0/taxonomy.schema.json | 45 +++ scripts/validate-fixtures.mjs | 48 ++- taxonomy/v0/taxonomy.json | 321 ++++++++++++++++++ 11 files changed, 797 insertions(+), 33 deletions(-) create mode 100644 fixtures/invalid/item-category-not-kebab.json create mode 100644 fixtures/invalid/item-category-unknown.json create mode 100644 fixtures/invalid/item-too-many-tags.json create mode 100644 fixtures/malicious/item-tag-markup.json create mode 100644 fixtures/valid/item-category-alias.json create mode 100644 fixtures/valid/item-with-taxonomy.json create mode 100644 schemas/v0/taxonomy.schema.json create mode 100644 taxonomy/v0/taxonomy.json diff --git a/fixtures/invalid/item-category-not-kebab.json b/fixtures/invalid/item-category-not-kebab.json new file mode 100644 index 0000000..d5187ed --- /dev/null +++ b/fixtures/invalid/item-category-not-kebab.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "0", + "namespace": "northstar", + "name": "calendar", + "version": "1.2.0", + "framework": "react", + "sourceModel": "open-source", + "visibility": "public", + "owner": { + "kind": "organization", + "identifier": "northstar-ui" + }, + "source": { + "repository": "https://github.com/northstar-ui/components", + "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "shadcnItem": { + "url": "https://registry.modulora.dev/r/northstar/calendar/1.2.0/registry-item.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "license": { + "kind": "spdx", + "spdxExpression": "MIT" + }, + "category": "Forms & Inputs" +} \ No newline at end of file diff --git a/fixtures/invalid/item-category-unknown.json b/fixtures/invalid/item-category-unknown.json new file mode 100644 index 0000000..d59acc2 --- /dev/null +++ b/fixtures/invalid/item-category-unknown.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "0", + "namespace": "northstar", + "name": "calendar", + "version": "1.2.0", + "framework": "react", + "sourceModel": "open-source", + "visibility": "public", + "owner": { + "kind": "organization", + "identifier": "northstar-ui" + }, + "source": { + "repository": "https://github.com/northstar-ui/components", + "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "shadcnItem": { + "url": "https://registry.modulora.dev/r/northstar/calendar/1.2.0/registry-item.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "license": { + "kind": "spdx", + "spdxExpression": "MIT" + }, + "category": "not-a-real-category" +} \ No newline at end of file diff --git a/fixtures/invalid/item-too-many-tags.json b/fixtures/invalid/item-too-many-tags.json new file mode 100644 index 0000000..bd68cb1 --- /dev/null +++ b/fixtures/invalid/item-too-many-tags.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": "0", + "namespace": "northstar", + "name": "calendar", + "version": "1.2.0", + "framework": "react", + "sourceModel": "open-source", + "visibility": "public", + "owner": { + "kind": "organization", + "identifier": "northstar-ui" + }, + "source": { + "repository": "https://github.com/northstar-ui/components", + "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "shadcnItem": { + "url": "https://registry.modulora.dev/r/northstar/calendar/1.2.0/registry-item.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "license": { + "kind": "spdx", + "spdxExpression": "MIT" + }, + "tags": [ + "tag-0", + "tag-1", + "tag-2", + "tag-3", + "tag-4", + "tag-5", + "tag-6", + "tag-7", + "tag-8", + "tag-9", + "tag-10" + ] +} \ No newline at end of file diff --git a/fixtures/malicious/item-tag-markup.json b/fixtures/malicious/item-tag-markup.json new file mode 100644 index 0000000..f272cc9 --- /dev/null +++ b/fixtures/malicious/item-tag-markup.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "0", + "namespace": "northstar", + "name": "calendar", + "version": "1.2.0", + "framework": "react", + "sourceModel": "open-source", + "visibility": "public", + "owner": { + "kind": "organization", + "identifier": "northstar-ui" + }, + "source": { + "repository": "https://github.com/northstar-ui/components", + "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "shadcnItem": { + "url": "https://registry.modulora.dev/r/northstar/calendar/1.2.0/registry-item.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "license": { + "kind": "spdx", + "spdxExpression": "MIT" + }, + "tags": [ + "" + ] +} \ No newline at end of file diff --git a/fixtures/manifest.json b/fixtures/manifest.json index 0f880b8..29da498 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -74,5 +74,32 @@ "valid/item-open-source.json": { "schema": "modulora-item", "valid": true + }, + "valid/item-with-taxonomy.json": { + "schema": "modulora-item", + "valid": true, + "taxonomy": true + }, + "invalid/item-category-not-kebab.json": { + "schema": "modulora-item", + "valid": false + }, + "invalid/item-too-many-tags.json": { + "schema": "modulora-item", + "valid": false + }, + "invalid/item-category-unknown.json": { + "schema": "modulora-item", + "valid": true, + "taxonomy": false + }, + "malicious/item-tag-markup.json": { + "schema": "modulora-item", + "valid": false + }, + "valid/item-category-alias.json": { + "schema": "modulora-item", + "valid": true, + "taxonomy": true } -} +} \ No newline at end of file diff --git a/fixtures/valid/item-category-alias.json b/fixtures/valid/item-category-alias.json new file mode 100644 index 0000000..f21a22f --- /dev/null +++ b/fixtures/valid/item-category-alias.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "0", + "namespace": "northstar", + "name": "calendar", + "version": "1.2.0", + "framework": "react", + "sourceModel": "open-source", + "visibility": "public", + "owner": { + "kind": "organization", + "identifier": "northstar-ui" + }, + "source": { + "repository": "https://github.com/northstar-ui/components", + "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "shadcnItem": { + "url": "https://registry.modulora.dev/r/northstar/calendar/1.2.0/registry-item.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "license": { + "kind": "spdx", + "spdxExpression": "MIT" + }, + "category": "forms-inputs" +} \ No newline at end of file diff --git a/fixtures/valid/item-with-taxonomy.json b/fixtures/valid/item-with-taxonomy.json new file mode 100644 index 0000000..030152f --- /dev/null +++ b/fixtures/valid/item-with-taxonomy.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": "0", + "namespace": "northstar", + "name": "calendar", + "version": "1.2.0", + "framework": "react", + "sourceModel": "open-source", + "visibility": "public", + "owner": { + "kind": "organization", + "identifier": "northstar-ui" + }, + "source": { + "repository": "https://github.com/northstar-ui/components", + "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "shadcnItem": { + "url": "https://registry.modulora.dev/r/northstar/calendar/1.2.0/registry-item.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "license": { + "kind": "spdx", + "spdxExpression": "MIT" + }, + "category": "layout", + "componentType": "date-picker", + "tags": [ + "calendar", + "range-select" + ] +} \ No newline at end of file diff --git a/schemas/v0/modulora-item.schema.json b/schemas/v0/modulora-item.schema.json index 496e85b..7e6ce7a 100644 --- a/schemas/v0/modulora-item.schema.json +++ b/schemas/v0/modulora-item.schema.json @@ -18,7 +18,9 @@ "license" ], "properties": { - "schemaVersion": { "const": "0" }, + "schemaVersion": { + "const": "0" + }, "namespace": { "type": "string", "minLength": 2, @@ -40,23 +42,43 @@ }, "framework": { "type": "string", - "enum": ["react"], + "enum": [ + "react" + ], "description": "Launch supports React only. Vue and Svelte are future additive values." }, "sourceModel": { "type": "string", - "enum": ["open-source", "external-commercial", "private-team", "hosted-commercial"] + "enum": [ + "open-source", + "external-commercial", + "private-team", + "hosted-commercial" + ] }, "visibility": { "type": "string", - "enum": ["public", "unlisted", "private"] + "enum": [ + "public", + "unlisted", + "private" + ] }, "owner": { "type": "object", "additionalProperties": false, - "required": ["kind", "identifier"], + "required": [ + "kind", + "identifier" + ], "properties": { - "kind": { "type": "string", "enum": ["user", "organization"] }, + "kind": { + "type": "string", + "enum": [ + "user", + "organization" + ] + }, "identifier": { "type": "string", "minLength": 1, @@ -68,59 +90,108 @@ "source": { "type": "object", "additionalProperties": false, - "required": ["repository", "commit"], + "required": [ + "repository", + "commit" + ], "properties": { "repository": { "type": "string", "maxLength": 300, "pattern": "^https://[a-z0-9.-]+/[A-Za-z0-9._/-]+$" }, - "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" } + "commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + } }, "description": "Canonical source reference. Required for open-source and private-team items." }, "shadcnItem": { "type": "object", "additionalProperties": false, - "required": ["url", "sha256"], + "required": [ + "url", + "sha256" + ], "properties": { "url": { "type": "string", "maxLength": 500, "pattern": "^https://[a-z0-9.-]+/\\S+$" }, - "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } }, "description": "Reference to the canonical shadcn registry-item document and its digest." }, "license": { "type": "object", "additionalProperties": false, - "required": ["kind"], + "required": [ + "kind" + ], "properties": { - "kind": { "type": "string", "enum": ["spdx", "commercial", "custom"] }, - "spdxExpression": { "type": "string", "minLength": 1, "maxLength": 200 }, - "url": { "type": "string", "maxLength": 500, "pattern": "^https://\\S+$" } + "kind": { + "type": "string", + "enum": [ + "spdx", + "commercial", + "custom" + ] + }, + "spdxExpression": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "url": { + "type": "string", + "maxLength": 500, + "pattern": "^https://\\S+$" + } }, "allOf": [ { - "if": { "properties": { "kind": { "const": "spdx" } } }, - "then": { "required": ["spdxExpression"] } + "if": { + "properties": { + "kind": { + "const": "spdx" + } + } + }, + "then": { + "required": [ + "spdxExpression" + ] + } } ] }, "purchase": { "type": "object", "additionalProperties": false, - "required": ["url", "domain"], + "required": [ + "url", + "domain" + ], "properties": { - "url": { "type": "string", "maxLength": 500, "pattern": "^https://\\S+$" }, + "url": { + "type": "string", + "maxLength": 500, + "pattern": "^https://\\S+$" + }, "domain": { "type": "string", "maxLength": 253, "pattern": "^(?!-)[a-z0-9-]{1,63}(? taxonomy.aliases[id] ?? id; + const inList = (list, id) => list.some((t) => t.id === id); + if (doc.category !== undefined) { + const id = resolve(doc.category); + if (!inList(taxonomy.categories, id)) return `unknown category "${doc.category}"`; + if (taxonomy.deprecated.includes(id)) return `deprecated category "${doc.category}"`; + } + if (doc.componentType !== undefined) { + const id = resolve(doc.componentType); + if (!inList(taxonomy.componentTypes, id)) return `unknown componentType "${doc.componentType}"`; + if (taxonomy.deprecated.includes(id)) return `deprecated componentType "${doc.componentType}"`; + } + return null; +} + const ajv = new Ajv2020.default({ allErrors: true, strict: true, strictRequired: false, strictTypes: false }); addFormats.default(ajv); const validators = Object.fromEntries( @@ -24,12 +48,34 @@ export function runFixtures() { const validate = validators[expectation.schema]; if (!validate) throw new Error(`unknown schema ${expectation.schema} for ${relPath}`); const valid = validate(doc); + let pass = valid === expectation.valid; + let errors = valid ? [] : (validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`); + // Optional second layer: taxonomy resolution (aliases → existence → deprecation). + if (pass && expectation.taxonomy !== undefined) { + const taxonomyError = taxonomyCheck(doc); + const taxonomyOk = taxonomyError === null; + pass = taxonomyOk === expectation.taxonomy; + if (!pass) errors = [taxonomyError ?? "expected a taxonomy failure but resolution succeeded"]; + } results.push({ fixture: relPath, schema: expectation.schema, expected: expectation.valid, actual: valid, - pass: valid === expectation.valid, + pass, + errors, + }); + } + // The taxonomy document must satisfy its own schema. + { + const validate = validators.taxonomy; + const valid = validate(taxonomy); + results.push({ + fixture: "taxonomy/v0/taxonomy.json", + schema: "taxonomy", + expected: true, + actual: valid, + pass: valid === true, errors: valid ? [] : (validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`), }); } diff --git a/taxonomy/v0/taxonomy.json b/taxonomy/v0/taxonomy.json new file mode 100644 index 0000000..a9b6e8e --- /dev/null +++ b/taxonomy/v0/taxonomy.json @@ -0,0 +1,321 @@ +{ + "$schema": "https://spec.modulora.dev/schemas/v0/taxonomy.schema.json", + "taxonomyVersion": "0.1.0", + "description": "Canonical category and component-type vocabulary. Category answers what a thing is for; componentType answers what it is. Aliases migrate renamed values without breaking existing releases; deprecated values remain valid for existing releases but are rejected for new publishes.", + "categories": [ + { + "id": "layout", + "label": "Layout" + }, + { + "id": "navigation", + "label": "Navigation" + }, + { + "id": "forms", + "label": "Forms & Inputs" + }, + { + "id": "data-display", + "label": "Data Display" + }, + { + "id": "date-time", + "label": "Date & Time" + }, + { + "id": "feedback", + "label": "Feedback & Overlays" + }, + { + "id": "media", + "label": "Media" + }, + { + "id": "commerce", + "label": "Commerce" + }, + { + "id": "marketing", + "label": "Marketing" + }, + { + "id": "charts", + "label": "Charts" + }, + { + "id": "ai", + "label": "AI & Chat" + }, + { + "id": "utilities", + "label": "Utilities" + } + ], + "componentTypes": [ + { + "id": "accordion", + "label": "Accordion" + }, + { + "id": "alert", + "label": "Alert" + }, + { + "id": "alert-dialog", + "label": "Alert Dialog" + }, + { + "id": "aspect-ratio", + "label": "Aspect Ratio" + }, + { + "id": "attachment", + "label": "Attachment" + }, + { + "id": "avatar", + "label": "Avatar" + }, + { + "id": "badge", + "label": "Badge" + }, + { + "id": "breadcrumb", + "label": "Breadcrumb" + }, + { + "id": "bubble", + "label": "Bubble" + }, + { + "id": "button", + "label": "Button" + }, + { + "id": "button-group", + "label": "Button Group" + }, + { + "id": "calendar", + "label": "Calendar" + }, + { + "id": "card", + "label": "Card" + }, + { + "id": "carousel", + "label": "Carousel" + }, + { + "id": "chart", + "label": "Chart" + }, + { + "id": "checkbox", + "label": "Checkbox" + }, + { + "id": "collapsible", + "label": "Collapsible" + }, + { + "id": "combobox", + "label": "Combobox" + }, + { + "id": "command", + "label": "Command" + }, + { + "id": "context-menu", + "label": "Context Menu" + }, + { + "id": "data-table", + "label": "Data Table" + }, + { + "id": "date-picker", + "label": "Date Picker" + }, + { + "id": "dialog", + "label": "Dialog" + }, + { + "id": "direction", + "label": "Direction" + }, + { + "id": "drawer", + "label": "Drawer" + }, + { + "id": "dropdown-menu", + "label": "Dropdown Menu" + }, + { + "id": "empty", + "label": "Empty" + }, + { + "id": "field", + "label": "Field" + }, + { + "id": "hover-card", + "label": "Hover Card" + }, + { + "id": "input", + "label": "Input" + }, + { + "id": "input-group", + "label": "Input Group" + }, + { + "id": "input-otp", + "label": "Input OTP" + }, + { + "id": "item", + "label": "Item" + }, + { + "id": "kbd", + "label": "Kbd" + }, + { + "id": "label", + "label": "Label" + }, + { + "id": "marker", + "label": "Marker" + }, + { + "id": "menubar", + "label": "Menubar" + }, + { + "id": "message", + "label": "Message" + }, + { + "id": "message-scroller", + "label": "Message Scroller" + }, + { + "id": "native-select", + "label": "Native Select" + }, + { + "id": "navigation-menu", + "label": "Navigation Menu" + }, + { + "id": "pagination", + "label": "Pagination" + }, + { + "id": "popover", + "label": "Popover" + }, + { + "id": "progress", + "label": "Progress" + }, + { + "id": "radio-group", + "label": "Radio Group" + }, + { + "id": "resizable", + "label": "Resizable" + }, + { + "id": "scroll-area", + "label": "Scroll Area" + }, + { + "id": "select", + "label": "Select" + }, + { + "id": "separator", + "label": "Separator" + }, + { + "id": "sheet", + "label": "Sheet" + }, + { + "id": "sidebar", + "label": "Sidebar" + }, + { + "id": "skeleton", + "label": "Skeleton" + }, + { + "id": "slider", + "label": "Slider" + }, + { + "id": "sonner", + "label": "Sonner" + }, + { + "id": "spinner", + "label": "Spinner" + }, + { + "id": "switch", + "label": "Switch" + }, + { + "id": "table", + "label": "Table" + }, + { + "id": "tabs", + "label": "Tabs" + }, + { + "id": "textarea", + "label": "Textarea" + }, + { + "id": "toast", + "label": "Toast" + }, + { + "id": "toggle", + "label": "Toggle" + }, + { + "id": "toggle-group", + "label": "Toggle Group" + }, + { + "id": "tooltip", + "label": "Tooltip" + }, + { + "id": "typography", + "label": "Typography" + }, + { + "id": "other", + "label": "Other" + } + ], + "aliases": { + "forms-inputs": "forms" + }, + "deprecated": [] +} \ No newline at end of file