diff --git a/.changeset/taxonomy-registry-split.md b/.changeset/taxonomy-registry-split.md new file mode 100644 index 00000000..a6a05c9e --- /dev/null +++ b/.changeset/taxonomy-registry-split.md @@ -0,0 +1,14 @@ +--- +"@adobe/design-system-registry": minor +"@adobe/design-data-spec": minor +--- + +Add taxonomy registries and expand token name object schema. + +- Split `anatomy-terms.json`: removed styling surfaces and positional terms +- Added `token-objects.json` (background, border, edge, visual, content) +- Added 6 new taxonomy registries: + structures, substructures, orientations, positions, densities, shapes +- Exported all 7 new registries from package index +- Added all 13 semantic fields explicitly to `nameObject` in + `token.schema.json`, distinguishing semantic from dimension fields diff --git a/packages/design-data-spec/schemas/token.schema.json b/packages/design-data-spec/schemas/token.schema.json index ba0c30d0..a0cde62d 100644 --- a/packages/design-data-spec/schemas/token.schema.json +++ b/packages/design-data-spec/schemas/token.schema.json @@ -15,30 +15,73 @@ "$defs": { "nameObject": { "type": "object", - "description": "Structured token identity; additional dimension keys MUST be strings.", + "description": "Structured token identity. Semantic fields describe identity and structure; dimension fields drive cascade resolution. See spec/taxonomy.md and spec/token-format.md. Additional dimension keys MUST be strings.", "required": ["property"], "properties": { "property": { "type": "string", - "minLength": 1 + "minLength": 1, + "description": "Semantic: the stylistic attribute being defined (e.g. color, width, padding, gap)." }, "component": { - "type": "string" + "type": "string", + "description": "Semantic: component scope (e.g. button, checkbox)." + }, + "structure": { + "type": "string", + "description": "Semantic: reusable visual pattern (e.g. base, container, list). Distinct from component." + }, + "substructure": { + "type": "string", + "description": "Semantic: child within a parent structure (e.g. item in list-item)." + }, + "anatomy": { + "type": "string", + "description": "Semantic: visible named part of a component (e.g. handle, icon, label)." + }, + "object": { + "type": "string", + "description": "Semantic: styling surface (e.g. background, border, edge, visual)." }, "variant": { - "type": "string" + "type": "string", + "description": "Semantic: variant within a component (e.g. accent, negative, primary)." }, "state": { - "type": "string" + "type": "string", + "description": "Semantic: interactive or semantic state (e.g. hover, focus, disabled)." + }, + "orientation": { + "type": "string", + "description": "Semantic: direction or order (e.g. vertical, horizontal)." + }, + "position": { + "type": "string", + "description": "Semantic: location relative to another object (e.g. affixed, top, bottom)." + }, + "size": { + "type": "string", + "description": "Semantic: relative t-shirt sizing (e.g. small, medium, large). Distinct from dimension scale." + }, + "density": { + "type": "string", + "description": "Semantic: space within or around component parts (e.g. spacious, compact)." + }, + "shape": { + "type": "string", + "description": "Semantic: overall component shape (e.g. uniform)." }, "colorScheme": { - "type": "string" + "type": "string", + "description": "Dimension: color scheme mode (e.g. light, dark, wireframe)." }, "scale": { - "type": "string" + "type": "string", + "description": "Dimension: platform density scale (e.g. desktop, mobile)." }, "contrast": { - "type": "string" + "type": "string", + "description": "Dimension: contrast level (e.g. regular, high)." } }, "additionalProperties": { @@ -98,7 +141,11 @@ "replaced_by": { "oneOf": [ { "type": "string", "format": "uuid" }, - { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1 } + { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "minItems": 1 + } ], "description": "UUID(s) of the replacement token(s). Array form requires deprecated_comment." }, @@ -151,7 +198,11 @@ "replaced_by": { "oneOf": [ { "type": "string", "format": "uuid" }, - { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1 } + { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "minItems": 1 + } ], "description": "UUID(s) of the replacement token(s). Array form requires deprecated_comment." }, diff --git a/packages/design-system-registry/index.js b/packages/design-system-registry/index.js index f14a6fbb..370b5560 100644 --- a/packages/design-system-registry/index.js +++ b/packages/design-system-registry/index.js @@ -62,6 +62,34 @@ export const glossary = JSON.parse( readFileSync(join(__dirname, "registry", "glossary.json"), "utf-8"), ); +export const tokenObjects = JSON.parse( + readFileSync(join(__dirname, "registry", "token-objects.json"), "utf-8"), +); + +export const structures = JSON.parse( + readFileSync(join(__dirname, "registry", "structures.json"), "utf-8"), +); + +export const substructures = JSON.parse( + readFileSync(join(__dirname, "registry", "substructures.json"), "utf-8"), +); + +export const orientations = JSON.parse( + readFileSync(join(__dirname, "registry", "orientations.json"), "utf-8"), +); + +export const positions = JSON.parse( + readFileSync(join(__dirname, "registry", "positions.json"), "utf-8"), +); + +export const densities = JSON.parse( + readFileSync(join(__dirname, "registry", "densities.json"), "utf-8"), +); + +export const shapes = JSON.parse( + readFileSync(join(__dirname, "registry", "shapes.json"), "utf-8"), +); + /** * Get all values from a registry by ID * @param {object} registry - The registry object diff --git a/packages/design-system-registry/package.json b/packages/design-system-registry/package.json index dea9e366..faf8f632 100644 --- a/packages/design-system-registry/package.json +++ b/packages/design-system-registry/package.json @@ -13,7 +13,14 @@ "./registry/components.json": "./registry/components.json", "./registry/scale-values.json": "./registry/scale-values.json", "./registry/categories.json": "./registry/categories.json", - "./registry/platforms.json": "./registry/platforms.json" + "./registry/platforms.json": "./registry/platforms.json", + "./registry/token-objects.json": "./registry/token-objects.json", + "./registry/structures.json": "./registry/structures.json", + "./registry/substructures.json": "./registry/substructures.json", + "./registry/orientations.json": "./registry/orientations.json", + "./registry/positions.json": "./registry/positions.json", + "./registry/densities.json": "./registry/densities.json", + "./registry/shapes.json": "./registry/shapes.json" }, "scripts": { "validate": "node scripts/validate-registry.js", diff --git a/packages/design-system-registry/registry/anatomy-terms.json b/packages/design-system-registry/registry/anatomy-terms.json index 9c90d076..fd6951b2 100644 --- a/packages/design-system-registry/registry/anatomy-terms.json +++ b/packages/design-system-registry/registry/anatomy-terms.json @@ -1,20 +1,8 @@ { "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", "type": "anatomy-term", - "description": "Anatomical part names used in design tokens and component structures", + "description": "Visible, named parts of components as defined by designers. These are the parts called out in component specification diagrams. Styling surfaces (background, border, etc.) belong in token-objects.json; positional terms (top, bottom, etc.) belong in positions.json.", "values": [ - { - "id": "edge", - "label": "Edge", - "description": "The outer boundary or border of a component", - "usedIn": ["tokens"] - }, - { - "id": "visual", - "label": "Visual", - "description": "Visual elements like icons, images, or decorative elements", - "usedIn": ["tokens"] - }, { "id": "text", "label": "Text", @@ -27,18 +15,6 @@ "description": "Interactive control elements like checkboxes or radio buttons", "usedIn": ["tokens"] }, - { - "id": "border", - "label": "Border", - "description": "Border or outline of a component", - "usedIn": ["tokens"] - }, - { - "id": "background", - "label": "Background", - "description": "Background surface or fill", - "usedIn": ["tokens"] - }, { "id": "icon", "label": "Icon", @@ -51,36 +27,6 @@ "description": "Text labels", "usedIn": ["tokens", "component-schemas"] }, - { - "id": "content", - "label": "Content", - "description": "Main content area", - "usedIn": ["tokens"] - }, - { - "id": "top", - "label": "Top", - "description": "Top edge or boundary", - "usedIn": ["tokens"] - }, - { - "id": "bottom", - "label": "Bottom", - "description": "Bottom edge or boundary", - "usedIn": ["tokens"] - }, - { - "id": "start", - "label": "Start", - "description": "Start edge (left in LTR, right in RTL)", - "usedIn": ["tokens"] - }, - { - "id": "end", - "label": "End", - "description": "End edge (right in LTR, left in RTL)", - "usedIn": ["tokens"] - }, { "id": "body", "label": "Body", diff --git a/packages/design-system-registry/registry/densities.json b/packages/design-system-registry/registry/densities.json new file mode 100644 index 00000000..d76c0a2f --- /dev/null +++ b/packages/design-system-registry/registry/densities.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "density", + "description": "Options that create more or less space within or around the parts of a component.", + "values": [ + { + "id": "spacious", + "label": "Spacious", + "description": "More space within and around component parts" + }, + { + "id": "compact", + "label": "Compact", + "description": "Less space within and around component parts" + } + ] +} diff --git a/packages/design-system-registry/registry/orientations.json b/packages/design-system-registry/registry/orientations.json new file mode 100644 index 00000000..5be0e59e --- /dev/null +++ b/packages/design-system-registry/registry/orientations.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "orientation", + "description": "Direction or order of structures and elements within a component or pattern.", + "values": [ + { + "id": "vertical", + "label": "Vertical", + "description": "Top-to-bottom direction" + }, + { + "id": "horizontal", + "label": "Horizontal", + "description": "Left-to-right (or right-to-left) direction" + } + ] +} diff --git a/packages/design-system-registry/registry/positions.json b/packages/design-system-registry/registry/positions.json new file mode 100644 index 00000000..ac83c39c --- /dev/null +++ b/packages/design-system-registry/registry/positions.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "position", + "description": "Location of an object relative to another, with or without respect to directional order.", + "values": [ + { + "id": "affixed", + "label": "Affixed", + "description": "Attached to another element in a fixed position" + }, + { + "id": "top", + "label": "Top", + "description": "Top position relative to a reference element" + }, + { + "id": "bottom", + "label": "Bottom", + "description": "Bottom position relative to a reference element" + }, + { + "id": "start", + "label": "Start", + "description": "Start position (left in LTR, right in RTL)" + }, + { + "id": "end", + "label": "End", + "description": "End position (right in LTR, left in RTL)" + } + ] +} diff --git a/packages/design-system-registry/registry/shapes.json b/packages/design-system-registry/registry/shapes.json new file mode 100644 index 00000000..a0fcf227 --- /dev/null +++ b/packages/design-system-registry/registry/shapes.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "shape", + "description": "Terms relative to the overall shape of a component. This is a starting vocabulary — new shape terms should be added here as design system patterns are identified.", + "values": [ + { + "id": "uniform", + "label": "Uniform", + "description": "Equal proportions (e.g., 1:1 ratio between horizontal and vertical padding)" + } + ] +} diff --git a/packages/design-system-registry/registry/structures.json b/packages/design-system-registry/registry/structures.json new file mode 100644 index 00000000..5fceb9f3 --- /dev/null +++ b/packages/design-system-registry/registry/structures.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "structure", + "description": "Reusable visual patterns or object categories that occur across many varieties of components. Distinct from components, which are specific UI elements.", + "values": [ + { + "id": "base", + "label": "Base", + "description": "Foundation structure shared across components" + }, + { + "id": "container", + "label": "Container", + "description": "Enclosing structure that holds child elements" + }, + { + "id": "list", + "label": "List", + "description": "Ordered or unordered collection of items" + }, + { + "id": "accessory", + "label": "Accessory", + "description": "Supplementary element attached to a primary structure" + } + ] +} diff --git a/packages/design-system-registry/registry/substructures.json b/packages/design-system-registry/registry/substructures.json new file mode 100644 index 00000000..910bf191 --- /dev/null +++ b/packages/design-system-registry/registry/substructures.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "substructure", + "description": "Structures that only exist within the context of a parent structure (e.g., item within a list).", + "values": [ + { + "id": "item", + "label": "Item", + "description": "Individual element within a parent structure (e.g., list item)" + } + ] +} diff --git a/packages/design-system-registry/registry/token-objects.json b/packages/design-system-registry/registry/token-objects.json new file mode 100644 index 00000000..e65b5bd8 --- /dev/null +++ b/packages/design-system-registry/registry/token-objects.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://opensource.adobe.com/spectrum-design-data/schemas/registry-value.json", + "type": "token-object", + "description": "Styling surfaces to which visual properties are applied. These are abstract targets that exist on any element regardless of component type. Not to be confused with component anatomy (visible named parts).", + "values": [ + { + "id": "background", + "label": "Background", + "description": "Background surface or fill" + }, + { + "id": "border", + "label": "Border", + "description": "Border or outline of a component" + }, + { + "id": "edge", + "label": "Edge", + "description": "Outer boundary of a component (used in spacing tokens)" + }, + { + "id": "visual", + "label": "Visual", + "description": "Visible graphic element area (may be inset from edge)" + }, + { + "id": "content", + "label": "Content", + "description": "Main content area" + } + ] +} diff --git a/packages/design-system-registry/test/registry.test.js b/packages/design-system-registry/test/registry.test.js index 2168d4dd..3eda1196 100644 --- a/packages/design-system-registry/test/registry.test.js +++ b/packages/design-system-registry/test/registry.test.js @@ -20,6 +20,13 @@ import { scaleValues, categories, platforms, + tokenObjects, + structures, + substructures, + orientations, + positions, + densities, + shapes, getValues, findValue, hasValue, @@ -243,10 +250,27 @@ test("variants includes semantic variants", (t) => { test("anatomyTerms includes key anatomy parts", (t) => { const ids = getValues(anatomyTerms); - t.true(ids.includes("edge")); - t.true(ids.includes("visual")); t.true(ids.includes("text")); t.true(ids.includes("icon")); + t.true(ids.includes("label")); + t.true(ids.includes("handle")); +}); + +test("anatomyTerms does not include styling surfaces", (t) => { + const ids = getValues(anatomyTerms); + t.false(ids.includes("background")); + t.false(ids.includes("border")); + t.false(ids.includes("edge")); + t.false(ids.includes("visual")); +}); + +test("tokenObjects includes styling surfaces", (t) => { + const ids = getValues(tokenObjects); + t.true(ids.includes("background")); + t.true(ids.includes("border")); + t.true(ids.includes("edge")); + t.true(ids.includes("visual")); + t.true(ids.includes("content")); }); test("components includes core components", (t) => { @@ -278,3 +302,63 @@ test("scaleValues includes common numeric scales", (t) => { t.true(ids.includes("200")); t.true(ids.includes("300")); }); + +// Taxonomy registry tests + +const taxonomyRegistries = [ + ["tokenObjects", tokenObjects], + ["structures", structures], + ["substructures", substructures], + ["orientations", orientations], + ["positions", positions], + ["densities", densities], + ["shapes", shapes], +]; + +for (const [name, registry] of taxonomyRegistries) { + test(`${name} registry loads successfully`, (t) => { + t.truthy(registry); + t.truthy(registry.values); + t.true(Array.isArray(registry.values)); + t.true(registry.values.length > 0); + }); + + test(`${name} registry has no duplicate IDs`, (t) => { + const ids = registry.values.map((v) => v.id); + const uniqueIds = new Set(ids); + t.is(ids.length, uniqueIds.size); + }); + + test(`all ${name} values have id and label`, (t) => { + registry.values.forEach((value) => { + t.truthy(value.id, `${name} value missing id`); + t.truthy(value.label, `${name} value ${value.id} missing label`); + }); + }); +} + +test("structures includes base and container", (t) => { + const ids = getValues(structures); + t.true(ids.includes("base")); + t.true(ids.includes("container")); +}); + +test("orientations includes vertical and horizontal", (t) => { + const ids = getValues(orientations); + t.true(ids.includes("vertical")); + t.true(ids.includes("horizontal")); +}); + +test("positions includes directional terms", (t) => { + const ids = getValues(positions); + t.true(ids.includes("top")); + t.true(ids.includes("bottom")); + t.true(ids.includes("start")); + t.true(ids.includes("end")); +}); + +test("densities includes spacious and compact", (t) => { + const ids = getValues(densities); + t.true(ids.includes("spacious")); + t.true(ids.includes("compact")); +}); diff --git a/sdk/core/src/figma/mapping.rs b/sdk/core/src/figma/mapping.rs index a248032e..599858a5 100644 --- a/sdk/core/src/figma/mapping.rs +++ b/sdk/core/src/figma/mapping.rs @@ -936,7 +936,10 @@ mod tests { let meta = mock_meta(); let (body, summary) = build_export_payload(dir.path(), &meta).unwrap(); - assert!(summary.skipped_alias_unresolved.is_empty(), "alias should resolve"); + assert!( + summary.skipped_alias_unresolved.is_empty(), + "alias should resolve" + ); // The alias variable must exist let alias_var = body @@ -957,8 +960,14 @@ mod tests { let r = mv.value.get("r").and_then(|v| v.as_f64()).unwrap_or(0.0); let b = mv.value.get("b").and_then(|v| v.as_f64()).unwrap_or(0.0); - assert!(r > 0.9, "expected light value (r≈1), got r={r} — dark value was used instead"); - assert!(b < 0.1, "expected light value (b≈0), got b={b} — dark value was used instead"); + assert!( + r > 0.9, + "expected light value (r≈1), got r={r} — dark value was used instead" + ); + assert!( + b < 0.1, + "expected light value (b≈0), got b={b} — dark value was used instead" + ); } #[test] diff --git a/sdk/core/src/lib.rs b/sdk/core/src/lib.rs index 660af92e..fe13dc84 100644 --- a/sdk/core/src/lib.rs +++ b/sdk/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod legacy; pub mod migrate; pub mod naming; pub mod query; +pub mod registry; pub mod report; pub mod schema; pub mod validate; diff --git a/sdk/core/src/registry.rs b/sdk/core/src/registry.rs new file mode 100644 index 00000000..18331fbe --- /dev/null +++ b/sdk/core/src/registry.rs @@ -0,0 +1,155 @@ +// Copyright 2026 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +//! Design system registry data embedded at compile time. +//! +//! Each registry JSON file from `packages/design-system-registry/registry/` is included +//! via `include_str!` and parsed into `HashSet` lookups keyed by value ID. +//! This avoids runtime filesystem access and keeps the validator self-contained. + +use std::collections::HashSet; + +use serde_json::Value; + +/// Parsed registry data for SPEC-009 enum validation. +#[derive(Debug, Clone)] +pub struct RegistryData { + pub components: HashSet, + pub states: HashSet, + pub variants: HashSet, + pub sizes: HashSet, + pub anatomy: HashSet, + pub token_objects: HashSet, + pub structures: HashSet, + pub substructures: HashSet, + pub orientations: HashSet, + pub positions: HashSet, + pub densities: HashSet, + pub shapes: HashSet, +} + +/// Parse a registry JSON file into a set of value IDs (and their aliases). +fn parse_registry(json_str: &str) -> HashSet { + let v: Value = serde_json::from_str(json_str).expect("embedded registry JSON must be valid"); + let mut set = HashSet::new(); + if let Some(values) = v.get("values").and_then(|v| v.as_array()) { + for entry in values { + if let Some(id) = entry.get("id").and_then(|v| v.as_str()) { + set.insert(id.to_string()); + } + if let Some(aliases) = entry.get("aliases").and_then(|v| v.as_array()) { + for alias in aliases { + if let Some(a) = alias.as_str() { + set.insert(a.to_string()); + } + } + } + } + } + set +} + +impl RegistryData { + /// Load all registries from embedded JSON (compile-time inclusion). + /// + /// Paths are relative to this source file (`sdk/core/src/registry.rs`), so + /// they resolve to `packages/design-system-registry/registry/`. If this + /// file is ever moved, update the paths accordingly — a compile-time error + /// will catch any mismatch. + pub fn embedded() -> Self { + Self { + components: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/components.json" + )), + states: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/states.json" + )), + variants: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/variants.json" + )), + sizes: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/sizes.json" + )), + anatomy: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/anatomy-terms.json" + )), + token_objects: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/token-objects.json" + )), + structures: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/structures.json" + )), + substructures: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/substructures.json" + )), + orientations: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/orientations.json" + )), + positions: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/positions.json" + )), + densities: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/densities.json" + )), + shapes: parse_registry(include_str!( + "../../../packages/design-system-registry/registry/shapes.json" + )), + } + } + + /// Look up which registry a name-object field should be validated against. + pub fn for_field(&self, field: &str) -> Option<&HashSet> { + match field { + "component" => Some(&self.components), + "state" => Some(&self.states), + "variant" => Some(&self.variants), + "size" => Some(&self.sizes), + "anatomy" => Some(&self.anatomy), + "object" => Some(&self.token_objects), + "structure" => Some(&self.structures), + "substructure" => Some(&self.substructures), + "orientation" => Some(&self.orientations), + "position" => Some(&self.positions), + "density" => Some(&self.densities), + "shape" => Some(&self.shapes), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_registries_load() { + let r = RegistryData::embedded(); + assert!(r.components.contains("button")); + assert!(r.states.contains("hover")); + assert!(r.variants.contains("accent")); + assert!(r.sizes.contains("m")); + assert!(r.anatomy.contains("icon")); + assert!(r.token_objects.contains("background")); + assert!(r.structures.contains("base")); + assert!(r.orientations.contains("vertical")); + assert!(r.positions.contains("top")); + assert!(r.densities.contains("compact")); + assert!(r.shapes.contains("uniform")); + } + + #[test] + fn for_field_returns_correct_registry() { + let r = RegistryData::embedded(); + assert!(r.for_field("component").unwrap().contains("button")); + assert!(r.for_field("object").unwrap().contains("background")); + assert!(r.for_field("property").is_none()); // free-form + assert!(r.for_field("colorScheme").is_none()); // dimension + } +} diff --git a/sdk/core/src/validate/rule.rs b/sdk/core/src/validate/rule.rs index b9900d43..081a692f 100644 --- a/sdk/core/src/validate/rule.rs +++ b/sdk/core/src/validate/rule.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use crate::graph::TokenGraph; +use crate::registry::RegistryData; use crate::report::Diagnostic; /// Context for relational rules. @@ -21,6 +22,8 @@ pub struct ValidationContext<'a> { /// Token names listed in the naming-exceptions allowlist. /// Empty when no exceptions file is loaded. pub naming_exceptions: &'a HashSet, + /// Design system registry data for SPEC-009 enum validation. + pub registry: &'a RegistryData, } /// Catalog-backed validation rule. diff --git a/sdk/core/src/validate/rules/mod.rs b/sdk/core/src/validate/rules/mod.rs index fbd60a18..d0ef1fe2 100644 --- a/sdk/core/src/validate/rules/mod.rs +++ b/sdk/core/src/validate/rules/mod.rs @@ -23,11 +23,19 @@ mod spec012; mod spec013; use std::collections::HashSet; +use std::sync::OnceLock; use crate::graph::TokenGraph; +use crate::registry::RegistryData; use crate::report::Diagnostic; use crate::validate::rule::{ValidationContext, ValidationRule}; +/// Lazily initialized embedded registry data (parsed once, reused). +fn embedded_registry() -> &'static RegistryData { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(RegistryData::embedded) +} + /// All default catalog rules (SPEC-001 … SPEC-013). pub fn default_rules() -> Vec> { vec![ @@ -49,9 +57,11 @@ pub fn default_rules() -> Vec> { /// Run every rule and collect diagnostics. pub fn run_rules(graph: &TokenGraph, naming_exceptions: &HashSet) -> Vec { + let registry = embedded_registry(); let ctx = ValidationContext { graph, naming_exceptions, + registry, }; let mut out = Vec::new(); for r in default_rules() { diff --git a/sdk/core/src/validate/rules/spec009.rs b/sdk/core/src/validate/rules/spec009.rs index 9cc50800..b62eb12c 100644 --- a/sdk/core/src/validate/rules/spec009.rs +++ b/sdk/core/src/validate/rules/spec009.rs @@ -10,26 +10,35 @@ //! SPEC-009: name-field-enum-sync //! -//! Recognized name-object fields (component, state, variant, etc.) SHOULD use -//! values from the corresponding design-system-registry enums when those enums -//! are available. -//! -//! This is a warning-only rule. When no registry data is present in the -//! `ValidationContext`, the rule emits no diagnostics. Full enforcement -//! requires loading the design-system-registry into `TokenGraph` (tracked in -//! #763). +//! Recognized name-object fields (component, state, variant, size, anatomy, +//! object, structure, substructure, orientation, position, density, shape) +//! SHOULD use values from the corresponding design-system-registry enums. //! +//! This is a warning-only rule (advisory severity per spec/taxonomy.md). //! Structural fields that are not enum-checked: `property` (free-form name), //! dimension keys (`colorScheme`, `scale`, `contrast`) which are validated by //! SPEC-005/SPEC-008 against dimension declarations. -use crate::report::Diagnostic; +use crate::report::{Diagnostic, Severity}; use crate::validate::rule::{ValidationContext, ValidationRule}; -/// Name-object fields whose values may be enum-validated when a registry is -/// loaded. Dimension keys (colorScheme, scale, contrast) are excluded here -/// because they are covered by dimension-declaration rules (SPEC-005/SPEC-008). -const ENUM_CHECKED_FIELDS: &[&str] = &["component", "state", "variant", "size"]; +/// Name-object fields whose values are enum-validated against the registry. +/// Dimension keys (colorScheme, scale, contrast) are excluded — they are +/// covered by SPEC-005/SPEC-008. `property` is free-form and excluded. +const ENUM_CHECKED_FIELDS: &[&str] = &[ + "component", + "state", + "variant", + "size", + "anatomy", + "object", + "structure", + "substructure", + "orientation", + "position", + "density", + "shape", +]; pub struct Rule; @@ -43,16 +52,43 @@ impl ValidationRule for Rule { } fn validate(&self, ctx: &ValidationContext<'_>) -> Vec { - // Registry enum data is not yet threaded into ValidationContext. - // When it is (see #763), iterate ctx.graph.tokens, check each - // ENUM_CHECKED_FIELDS value against the registry, and emit - // Severity::Warning diagnostics for unknown values. - // - // The field list below is referenced to ensure it compiles and is - // linked into the binary even before registry support is added. - let _ = ENUM_CHECKED_FIELDS; - let _ = ctx; - Vec::new() + let mut diags = Vec::new(); + + for record in ctx.graph.tokens.values() { + let name_obj = match record.raw.get("name").and_then(|v| v.as_object()) { + Some(n) => n, + None => continue, + }; + + for &field in ENUM_CHECKED_FIELDS { + let value = match name_obj.get(field).and_then(|v| v.as_str()) { + Some(v) => v, + None => continue, + }; + + let registry_set = match ctx.registry.for_field(field) { + Some(s) => s, + None => continue, + }; + + if !registry_set.contains(value) { + diags.push(Diagnostic { + file: record.file.clone(), + token: Some(record.name.clone()), + rule_id: Some("SPEC-009".into()), + severity: Severity::Warning, + message: format!( + "name.{field} value \"{value}\" is not in the design-system-registry \ + {field} vocabulary" + ), + instance_path: Some(format!("/name/{field}")), + schema_path: None, + }); + } + } + } + + diags } } @@ -68,13 +104,102 @@ mod tests { use crate::validate::relational::diagnostics_for_rule; #[test] - fn no_diagnostics_without_registry() { - // Rule emits nothing until registry data is available. + fn valid_component_no_warning() { + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color", "component": "button"}, "value": "#fff"}), + )]); + assert!(diagnostics_for_rule(&g, "SPEC-009").is_empty()); + } + + #[test] + fn unknown_component_warns() { + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color", "component": "nonexistent-widget"}, "value": "#fff"}), + )]); + let diags = diagnostics_for_rule(&g, "SPEC-009"); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("nonexistent-widget")); + assert!(diags[0].message.contains("component")); + } + + #[test] + fn valid_state_no_warning() { let g = TokenGraph::from_pairs(vec![( "t".into(), PathBuf::from("a.tokens.json"), - json!({"name": {"property": "bg", "component": "button"}, "value": "#fff"}), + json!({"name": {"property": "color", "state": "hover"}, "value": "#fff"}), )]); assert!(diagnostics_for_rule(&g, "SPEC-009").is_empty()); } + + #[test] + fn valid_object_no_warning() { + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color", "object": "background"}, "value": "#fff"}), + )]); + assert!(diagnostics_for_rule(&g, "SPEC-009").is_empty()); + } + + #[test] + fn anatomy_background_warns_after_split() { + // "background" was moved from anatomy to token-objects — using it as + // anatomy should produce a warning. + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color", "anatomy": "background"}, "value": "#fff"}), + )]); + let diags = diagnostics_for_rule(&g, "SPEC-009"); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("anatomy")); + } + + #[test] + fn no_enum_fields_no_warning() { + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color"}, "value": "#fff"}), + )]); + assert!(diagnostics_for_rule(&g, "SPEC-009").is_empty()); + } + + #[test] + fn property_field_not_checked() { + // "property" is free-form — any value is allowed. + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "some-custom-css-thing"}, "value": "10px"}), + )]); + assert!(diagnostics_for_rule(&g, "SPEC-009").is_empty()); + } + + #[test] + fn dimension_fields_not_checked() { + // colorScheme, scale, contrast are validated by SPEC-005/008, not here. + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color", "colorScheme": "nonexistent"}, "value": "#fff"}), + )]); + assert!(diagnostics_for_rule(&g, "SPEC-009").is_empty()); + } + + #[test] + fn multiple_unknown_fields_multiple_warnings() { + let g = TokenGraph::from_pairs(vec![( + "t".into(), + PathBuf::from("a.tokens.json"), + json!({"name": {"property": "color", "component": "nope", "state": "nah"}, "value": "#fff"}), + )]); + let diags = diagnostics_for_rule(&g, "SPEC-009"); + assert_eq!(diags.len(), 2); + } }