ADR
Fix schema compliance gaps and improve schema URL metadata
Context
Schema compliance testing (anova-dev-testing test 0008) validated the library spec output from both CLI and Plugin against component.schema.json and styles.schema.json. The test found 58 violations across 26 components, all appearing in both sources — indicating schema gaps rather than output bugs. The violations collapse to three distinct root causes:
-
SlotProp.defaultis required but not always emitted — The transformer outputs slot props as{ type: "slot" }when there is no meaningful default. The schema requiresdefault(required: ["type", "default"]), causing validation failure. The TypeScript type also declaresdefaultas required (default: string | null). Both need to make it optional. -
Props with
$extensionsfailadditionalProperties: false— The transformer emits$extensionsmetadata on some props (e.g.,{ "com.figma": { "visibilityProp": "labelVisible" } }on visibility-toggled string props). All four prop definitions useadditionalProperties: falseand don’t permit$extensionsor any$-prefixed metadata fields. -
ColorStyleValuerejects bare hex strings — The schema’sColorStyleValueoneOf acceptsColorValue(structured DTCG object),TokenReference,GradientValue, ornull. The TypeScriptColorStyletype already includesstringin its union (string | TokenReference | GradientValue | null), but the schema does not. This is an existing type–schema drift (Constitution I). The transformer correctly emits bare hex strings (e.g.,"#666E74") and hex-with-opacity (e.g.,"#0E1114, 50%") for untokenized colors. The schema must be widened to match the type. -
Metadata.schemalacks a versioned URL and discovery link — A user report (test 0009) revealed that theschema.urlfield in generated output points to a 404 (https://github.com/DirectedEdges/anova/blob/main/anova.schema.json). The actual schemas live inschema/*.schema.json. Furthermore, the URL is unversioned (mainbranch) while the output carries aschema.versionfield — users validating old output against the latestmainschema get false failures because the schema has evolved. TheMetadatatype and schema need alatestfield for schema discovery alongside the existing versionedurl.
Decision Drivers
- Type–schema symmetry (Constitution I): Types and schema must describe the same structure.
ColorStylealready includesstring; the schema must match. - Schema validity (Constitution IV): The schema must be mechanically verifiable.
oneOfdiscrimination between prop types must be preserved. - Additive-only when possible (Constitution III): All three fixes widen existing definitions — no fields are removed or renamed.
- No runtime logic (Constitution II): All changes are purely declarative.
- DTCG extensibility convention: The
$-prefix pattern for metadata extensions is established byTokenReference.$extensionsand aligns with DTCG §5.2.3. - Discoverability: Users and LLMs should be able to find the correct schema from the output metadata alone. A broken URL and version-agnostic link undermine troubleshooting.
Options Considered
Option A: Four targeted fixes — schema compliance + metadata URL improvement (Selected)
- Make
SlotProp.defaultoptional (remove fromrequiredarray) - Add
patternProperties: { "^\\$": {} }to all four prop definitions to allow$-prefixed metadata fields while preservingadditionalProperties: false - Add
{ "type": "string" }branch toColorStyleValueoneOf - Add
latestfield toMetadata.schemafor stable discovery URL; clarifyurlas the versioned schema URL
Pros:
- Preserves
oneOfdiscrimination betweenStringPropandEnumProp—additionalProperties: falsestill rejects non-$-prefixed unknown fields patternPropertiesis implicit rather than explicit — allows any$-prefixed metadata, not just$extensions, which is consistent with howTokenReferenceuses the$prefix convention- Resolves all 58 violations
ColorStyleValuestring branch restores type–schema symmetry that was already driftedMetadata.schema.latestgives users and LLMs a working discovery URL; versionedurlenables exact-version validation and troubleshooting
Cons / Trade-offs:
patternPropertiesis a less common JSON Schema pattern — consumers validating with custom tooling may need to account for it- Bare
stringinColorStyleValueis a weaker contract than requiring structuredColorValueobjects — but the TypeScript type already permits this - Adding
latestintroduces a field that will always point tomain— consumers must understand thaturlis the pinned version andlatestmay be ahead
Option B: Remove additionalProperties: false from prop definitions (Rejected)
Remove additionalProperties: false entirely from BooleanProp, StringProp, EnumProp, and SlotProp.
Rejected because: This re-breaks oneOf discrimination between StringProp and EnumProp — the exact issue resolved by ADR 015. Without additionalProperties: false, a value like { type: "string", default: "foo", enum: ["foo", "bar"] } would match both StringProp and EnumProp, causing oneOf to reject it.
Option C: Explicitly add $extensions property to each prop definition (Rejected)
Add a named $extensions property with full type definition to each of BooleanProp, StringProp, EnumProp, and SlotProp.
Rejected because: Overly specific — ties the schema to one particular extension key rather than supporting the general $-prefix convention. Also requires maintaining parallel $extensions definitions across four prop types plus TokenReference. The patternProperties approach is more extensible with less maintenance burden.
Decision
Fix 1: Make SlotProp.default optional
Type changes (types/)
| File | Change | Bump |
|---|---|---|
Props.ts | Make SlotProp.default optional: default: string | null → default?: string | null | MINOR |
Example — new shape (types/Props.ts):
# BeforeSlotProp: type: 'slot' default: string | null # required nullable?: boolean
# AfterSlotProp: type: 'slot' default?: string | null # optional nullable?: booleanSchema changes (schema/)
| File | Change | Bump |
|---|---|---|
component.schema.json | Remove "default" from SlotProp.required array: ["type", "default"] → ["type"] | MINOR |
Example — new shape (schema/component.schema.json):
# Before — SlotProprequired: ["type", "default"]
# After — SlotProprequired: ["type"]Fix 2: Allow $-prefixed metadata on all prop types
Type changes (types/)
No TypeScript changes needed. TypeScript interfaces with specific fields already allow extra properties at runtime — there is no additionalProperties: false equivalent in TypeScript. The existing interfaces are compatible.
Schema changes (schema/)
| File | Change | Bump |
|---|---|---|
component.schema.json | Add patternProperties: { "^\\$": {} } to BooleanProp | MINOR |
component.schema.json | Add patternProperties: { "^\\$": {} } to StringProp | MINOR |
component.schema.json | Add patternProperties: { "^\\$": {} } to EnumProp | MINOR |
component.schema.json | Add patternProperties: { "^\\$": {} } to SlotProp | MINOR |
Example — new shape (schema/component.schema.json, shown for StringProp):
# Before — StringPropproperties: type: { type: string, const: "string" } default: { type: ["string", "null"] } nullable: { type: boolean } examples: { type: array, items: { type: string } }additionalProperties: false
# After — StringPropproperties: type: { type: string, const: "string" } default: { type: ["string", "null"] } nullable: { type: boolean } examples: { type: array, items: { type: string } }patternProperties: "^\\$": {} # allows $extensions and any $-prefixed metadataadditionalProperties: false # still rejects non-$-prefixed unknown fieldsFix 3: Add string branch to ColorStyleValue
Type changes (types/)
| File | Change | Bump |
|---|---|---|
| (none) | ColorStyle in Styles.ts already includes string in its union — no change needed | — |
Schema changes (schema/)
| File | Change | Bump |
|---|---|---|
styles.schema.json | Add { "type": "string" } branch to ColorStyleValue oneOf | MINOR |
Example — new shape (schema/styles.schema.json):
# Before — ColorStyleValue oneOf- $ref: "#/definitions/ColorValue"- $ref: "#/definitions/TokenReference"- $ref: "#/definitions/GradientValue"- type: "null"
# After — ColorStyleValue oneOf- $ref: "#/definitions/ColorValue"- $ref: "#/definitions/TokenReference"- $ref: "#/definitions/GradientValue"- type: string- type: "null"Notes
- The
stringbranch covers both bare hex ("#666E74") and hex-with-opacity ("#0E1114, 50%") formats. Nopatternconstraint is applied — the schema defers format validation to consumers, consistent with howStyle(the general style value type) already acceptsstringwithout pattern constraints. - This change resolves an existing type–schema drift:
ColorStyleinStyles.tshas includedstringsince its introduction, but the schema never had a matching branch. - ADR 022 (
nullable-slot-props) proposed wideningSlotProp.defaultto acceptnulland addingnullable. That ADR noteddefaultremains required. This ADR extends that decision by makingdefaultoptional — addressing the case where slot props have no meaningful default value at all.
Fix 4: Add latest field to Metadata.schema and clarify url semantics
Type changes (types/)
| File | Change | Bump |
|---|---|---|
Metadata.ts | Add optional latest?: string to Metadata.schema | MINOR |
Metadata.ts | Add JSDoc to url clarifying it is the versioned schema URL | PATCH |
Example — new shape (types/Metadata.ts):
# Beforeschema: url: string version: string
# Afterschema: url: string # versioned URL pinned to this output's schema (e.g. raw.githubusercontent.com/.../v0.13.0/schema/component.schema.json) version: string latest?: string # stable URL to latest schema on main (e.g. raw.githubusercontent.com/.../main/schema/component.schema.json)Schema changes (schema/)
| File | Change | Bump |
|---|---|---|
component.schema.json | Add latest string property to Metadata.schema (not in required) | MINOR |
component.schema.json | Add description to url and latest properties | PATCH |
Example — new shape (schema/component.schema.json, Metadata.schema):
# Before — Metadata/properties/schemaproperties: url: { type: string } version: { type: string }required: ["url", "version"]additionalProperties: false
# After — Metadata/properties/schemaproperties: url: type: string description: "Versioned schema URL pinned to a git tag (e.g. https://raw.githubusercontent.com/.../v0.13.0/schema/component.schema.json)" version: type: string latest: type: string description: "Stable URL pointing to the latest schema on the main branch for discovery"required: ["url", "version"]additionalProperties: falseNotes
urlsemantics change from “arbitrary link” to “versioned raw URL that resolves to the exact schema this output was generated against.” This is a documentation/convention change — the type staysstring, so it is non-breaking. The downstream transformer (METADATA.SCHEMA_URL) must update its value to use a versioned git tag URL (e.g.,https://raw.githubusercontent.com/DirectedEdges/anova/v0.13.0/schema/component.schema.json).latestis optional — older output without it remains valid. Producers should emit it for discoverability.raw.githubusercontent.comURLs are directly fetchable (returns JSON), unlikegithub.com/blob/URLs (returns HTML). This matters for programmatic validation and LLM tool use.- The transformer must also derive
SCHEMA_VERSIONfrom the@directededges/anovapackage version rather than hardcoding it — but that is a transformer-side implementation detail, not a type/schema change.
Type ↔ Schema Impact
- Symmetric: Yes for Fixes 1, 3, and 4. Fix 2 is schema-only but compatible — TypeScript interfaces don’t enforce
additionalProperties: false, so they already permit extra fields. - Parity check:
SlotProp.default?: string | null↔SlotProp.required: ["type"]+default.type: ["null", "string"]ColorStyleincludesstring↔ColorStyleValue.oneOfincludes{ "type": "string" }patternPropertieshas no TypeScript counterpart — no drift; TypeScript is inherently open to extra properties on interfacesMetadata.schema.latest?: string↔Metadata/properties/schema/properties/latest: { type: string }(not inrequired)
Downstream Impact
| Consumer | Impact | Action required |
|---|---|---|
anova-kit | Recompile — SlotProp.default is now optional; Metadata.schema.latest is available | Add optional-chain when accessing SlotProp.default; no action needed for latest (optional field) |
Semver Decision
Version bump: MINOR (part of 0.13.0 pre-release)
Justification: All changes are additive — making a required field optional, allowing additional properties via patternProperties, and adding a new branch to a oneOf. No existing valid values are rejected. Per Constitution III: “MINOR for additive types or new optional fields.”
Consequences
- All 58 schema compliance violations from test 0008 are resolved
- Schema validation of CLI and Plugin output will pass cleanly for the
libraryspec SlotPropinstances without adefaultfield are now valid — producers are no longer forced to emit a synthetic default for slot props- Props can carry
$-prefixed metadata (e.g.,$extensions) without violatingadditionalProperties— consistent with the DTCG extension convention used byTokenReference ColorStyleValueaccepts bare hex strings, matching the existingColorStyleTypeScript type — type–schema drift is resolvedoneOfdiscrimination betweenStringPropandEnumPropis preserved —additionalProperties: falsestill rejects non-pattern-matched unknown fields- Generated output metadata includes a working versioned schema URL (
url) and a stable discovery URL (latest), enabling users and LLMs to find and validate against the correct schema version - The version-mismatch class of validation failures (test 0009) becomes self-diagnosable — users can compare
metadata.schema.versionagainst the schema they’re validating with