From 4d7ec1e814a5c9cd69e4685d00c2cedffe132676 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Thu, 20 Aug 2026 23:52:52 +0200 Subject: [PATCH] feat(tokens): fail the build on colliding token names with different values Style-dictionary's name/kebab flattening can map two distinct token paths to one CSS custom property, and the later definition silently wins. The validator derives names by invoking SD's own name/kebab transform, so the check cannot drift from the build, and aborts on any collision whose values differ. --- packages/tokens/src/index.ts | 7 ++ .../tokens/src/validate-collisions.spec.ts | 71 ++++++++++++ packages/tokens/src/validate-collisions.ts | 101 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 packages/tokens/src/validate-collisions.spec.ts create mode 100644 packages/tokens/src/validate-collisions.ts diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 832b3ac58..2f22cc6e7 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -2,10 +2,12 @@ import fs from 'node:fs'; import { tokensToCss } from './tokens-to-css'; +import tokens from '../tokens.json'; import { OUTPUT_DIR } from './constants'; import { ejectTokens } from './eject-tokens'; import { generateCSSBundle } from './generate-css-bundle'; import { buildManifest } from './manifest'; +import { assertNoValueCollisions } from './validate-collisions'; // Stale outputs must not survive a rebuild: a renamed set leaves its old file // behind, and an incremental UI build would ship it as if it were current. @@ -13,6 +15,11 @@ fs.rmSync(OUTPUT_DIR, { recursive: true, force: true }); const manifest = buildManifest(); +assertNoValueCollisions( + tokens, + [...manifest.primitives, ...manifest.themes].map((entry) => entry.key), +); + ejectTokens(manifest); await tokensToCss(manifest); await generateCSSBundle(manifest); diff --git a/packages/tokens/src/validate-collisions.spec.ts b/packages/tokens/src/validate-collisions.spec.ts new file mode 100644 index 000000000..50037a1b9 --- /dev/null +++ b/packages/tokens/src/validate-collisions.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { assertNoValueCollisions, findCssNameCollisions } from './validate-collisions'; + +describe('findCssNameCollisions', () => { + it('finds names that kebab to the same CSS custom property', () => { + const collisions = findCssNameCollisions({ + ax: { + colors: { + 'acc7- 100': { value: '#cfd0d6', type: 'color' }, + 'acc7-100': { value: '#cfd0d6', type: 'color' }, + 'acc7-200': { value: '#a0a1ad', type: 'color' }, + }, + }, + }); + + expect(collisions).toEqual([ + { + cssName: '--ax-colors-acc7-100', + entries: [ + { path: 'ax/colors/acc7- 100', value: '#cfd0d6' }, + { path: 'ax/colors/acc7-100', value: '#cfd0d6' }, + ], + sameValue: true, + }, + ]); + }); + + it('returns nothing for distinct names', () => { + expect( + findCssNameCollisions({ + ax: { colors: { 'gray-100': { value: '#eee', type: 'color' } } }, + }), + ).toEqual([]); + }); +}); + +describe('assertNoValueCollisions', () => { + it('only warns when the colliding values are identical', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const tokens = { + 'Primitives/Mode 1': { + ax: { + colors: { + 'acc7- 100': { value: '#cfd0d6', type: 'color' }, + 'acc7-100': { value: '#cfd0d6', type: 'color' }, + }, + }, + }, + }; + + expect(() => assertNoValueCollisions(tokens, ['Primitives/Mode 1'])).not.toThrow(); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + it('throws when one CSS name would carry different values', () => { + const tokens = { + 'Primitives/Mode 1': { + ax: { + colors: { + 'acc7- 100': { value: '#ffffff', type: 'color' }, + 'acc7-100': { value: '#cfd0d6', type: 'color' }, + }, + }, + }, + }; + + expect(() => assertNoValueCollisions(tokens, ['Primitives/Mode 1'])).toThrow(/--ax-colors-acc7-100/); + }); +}); diff --git a/packages/tokens/src/validate-collisions.ts b/packages/tokens/src/validate-collisions.ts new file mode 100644 index 000000000..0dd2cdd9a --- /dev/null +++ b/packages/tokens/src/validate-collisions.ts @@ -0,0 +1,101 @@ +/** + * Detects token names that become the same CSS custom property after the + * Style Dictionary kebab transform — e.g. the Figma-side duplicate + * 'ax/colors/acc7- 100' (stray space) vs 'ax/colors/acc7-100', which both + * emit `--ax-colors-acc7-100` and silently overwrite each other in the + * built CSS. + * + * Same-value collisions warn (today's export carries ten of them, all + * benign); different-value collisions throw, because the emitted value + * would then depend on object iteration order. + */ +import StyleDictionary, { TransformedToken } from 'style-dictionary'; + +type TokenLeaf = { value: unknown; type: string }; +type TokenNode = TokenLeaf | { [key: string]: TokenNode }; + +type CssNameCollision = { + cssName: string; + entries: { path: string; value: unknown }[]; + sameValue: boolean; +}; + +function isLeaf(node: TokenNode): node is TokenLeaf { + return typeof node === 'object' && node !== null && 'value' in node && 'type' in node; +} + +function collectLeaves(node: TokenNode, path: string[], out: { path: string; value: unknown }[]) { + if (isLeaf(node)) { + out.push({ path: path.join('/'), value: node.value }); + return; + } + for (const [key, child] of Object.entries(node)) { + collectLeaves(child as TokenNode, [...path, key], out); + } +} + +const kebabName = StyleDictionary.hooks.transforms['name/kebab'].transform; + +/** Must match Style Dictionary's `name/kebab` output — it IS that transform, + * invoked directly, so the two cannot drift. */ +function toCssName(tokenPath: string): string { + return `--${kebabName({ path: tokenPath.split('/') } as TransformedToken, {}, {})}`; +} + +export function findCssNameCollisions(tokenSet: Record): CssNameCollision[] { + const leaves: { path: string; value: unknown }[] = []; + for (const [key, node] of Object.entries(tokenSet)) { + collectLeaves(node, [key], leaves); + } + + const byCssName = new Map(); + for (const leaf of leaves) { + const cssName = toCssName(leaf.path); + byCssName.set(cssName, [...(byCssName.get(cssName) ?? []), leaf]); + } + + return [...byCssName.entries()] + .filter(([, entries]) => entries.length > 1) + .map(([cssName, entries]) => ({ + cssName, + entries, + sameValue: new Set(entries.map((entry) => JSON.stringify(entry.value))).size === 1, + })); +} + +/** + * Validates every configured set in the raw tokens.json export. Different + * values behind one CSS name fail the build; identical values only warn so + * the known Figma-side duplicates don't block builds until design removes + * them at the source. + */ +export function assertNoValueCollisions(tokens: Record, setKeys: string[]): void { + for (const setKey of setKeys) { + const tokenSet = tokens[setKey]; + if (!tokenSet) { + throw new Error(`tokens.json does not export a '${setKey}' set`); + } + const collisions = findCssNameCollisions(tokenSet as Record); + const conflicting = collisions.filter((collision) => !collision.sameValue); + + for (const collision of collisions.filter((c) => c.sameValue)) { + console.warn( + `tokens: '${setKey}' exports duplicate names for ${collision.cssName} ` + + `(${collision.entries.map((entry) => `'${entry.path}'`).join(', ')}) — same value, ` + + 'the built CSS keeps one copy; remove the duplicate in Figma.', + ); + } + + if (conflicting.length > 0) { + throw new Error( + conflicting + .map( + (collision) => + `'${setKey}': ${collision.entries.map((entry) => `'${entry.path}' (${JSON.stringify(entry.value)})`).join(' and ')} ` + + `all emit ${collision.cssName} with different values — the winner would depend on iteration order`, + ) + .join('\n'), + ); + } + } +}