Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/cli/src/linter/model/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,96 @@ describe('ModelHandler', () => {
expect(result.designSystem.symbolTable.has('colors.theme.surface.background.base')).toBe(true);
});

it('emits diagnostic for duplicate token path in colors', () => {
const result = handler.execute(makeParsed({
colors: {
'utility-info': {
'50': '#111111',
},
'utility-info.50': '#222222',
},
}));
const errors = result.findings.filter(f => f.severity === 'error');
expect(errors.length).toBe(1);
expect(errors[0]!.path).toBe('colors.utility-info.50');
expect(errors[0]!.message).toBe("Duplicate token path 'colors.utility-info.50' detected.");
});

it('emits diagnostic when grouped color token flattens to an existing token name', () => {
const result = handler.execute(makeParsed({
colors: {
'utility-info-50': '#111111',
'utility-info': {
'50': '#222222',
}
},
}));
const errors = result.findings.filter(f => f.severity === 'error');
expect(errors.length).toBe(1);
expect(errors[0]!.path).toBe('colors.utility-info.50');
expect(errors[0]!.message).toBe("Grouped colors token flattens to 'utility-info-50', which is already defined.");
});

it('emits diagnostic for duplicate token path in rounded', () => {
const result = handler.execute(makeParsed({
rounded: {
'button': {
'lg': '8px',
},
'button.lg': '12px',
},
}));
const errors = result.findings.filter(f => f.severity === 'error');
expect(errors.length).toBe(1);
expect(errors[0]!.path).toBe('rounded.button.lg');
expect(errors[0]!.message).toBe("Duplicate token path 'rounded.button.lg' detected.");
});

it('emits diagnostic when grouped rounded token flattens to an existing token name', () => {
const result = handler.execute(makeParsed({
rounded: {
'button-lg': '8px',
'button': {
'lg': '12px',
}
},
}));
const errors = result.findings.filter(f => f.severity === 'error');
expect(errors.length).toBe(1);
expect(errors[0]!.path).toBe('rounded.button.lg');
expect(errors[0]!.message).toBe("Grouped rounded token flattens to 'button-lg', which is already defined.");
});

it('emits diagnostic for duplicate token path in spacing', () => {
const result = handler.execute(makeParsed({
spacing: {
'gutter': {
's': '8px',
},
'gutter.s': '12px',
},
}));
const errors = result.findings.filter(f => f.severity === 'error');
expect(errors.length).toBe(1);
expect(errors[0]!.path).toBe('spacing.gutter.s');
expect(errors[0]!.message).toBe("Duplicate token path 'spacing.gutter.s' detected.");
});

it('emits diagnostic when grouped spacing token flattens to an existing token name', () => {
const result = handler.execute(makeParsed({
spacing: {
'gutter-s': '8px',
'gutter': {
's': '12px',
}
},
}));
const errors = result.findings.filter(f => f.severity === 'error');
expect(errors.length).toBe(1);
expect(errors[0]!.path).toBe('spacing.gutter.s');
expect(errors[0]!.message).toBe("Grouped spacing token flattens to 'gutter-s', which is already defined.");
});

it('resolves standard CSS named colors and converts them to hex/sRGB', () => {
const result = handler.execute(makeParsed({
colors: { c1: 'red', c2: 'transparent', c3: 'aliceblue' },
Expand Down
117 changes: 69 additions & 48 deletions packages/cli/src/linter/model/handler.ts

@Emp1500 Emp1500 Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 2 can undo the collision guard. This one's a bit sneaky because it's not in your diff, but the new guard in Phase 1 interacts with the existing Phase 2 reference-resolution loop in a way that can silently corrupt the symbol table.
Phase 2 does a bare forEachLeaf walk over input.colors again with no awareness of seenKeys or seenNormalized. So in a case like this:

colors:
utility-info-50: "{colors.brand.primary}" # token reference
utility-info:50: "#FFFFFF"

Phase 1 does the right thing detects the collision, emits the error, skips utility-info.50. But then Phase 2 comes along, re-walks input.colors, and when it resolves the reference for utility-info-50 it calls symbolTable.set() again no guard, no check. Whatever Phase 1 decided gets quietly overwritten. Same issue exists in the rounded and spacing Phase 2 loops.
The straightforward fix is to not re-walk input.colors in Phase 2 at all — just iterate over symbolTable directly and resolve any entries that are still raw reference strings. That way Phase 2 only touches what Phase 1 actually registered.

Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ export class ModelHandler implements ModelSpec {
// ── Phase 1: Resolve primitive tokens ──────────────────────────
// Colors
if (input.colors) {
const isCollision = buildCollisionGuard('colors', findings);
forEachLeaf(input.colors, (name, raw) => {
if (isCollision(name)) return;

if (typeof raw === 'string' && isTokenReference(raw)) {
// Store raw reference for later resolution
symbolTable.set(`colors.${name}`, raw);
Expand Down Expand Up @@ -85,7 +88,10 @@ export class ModelHandler implements ModelSpec {

// Rounded
if (input.rounded) {
const isCollision = buildCollisionGuard('rounded', findings);
forEachLeaf(input.rounded, (name, raw) => {
if (isCollision(name)) return;

if (typeof raw === 'string') {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
Expand Down Expand Up @@ -114,7 +120,10 @@ export class ModelHandler implements ModelSpec {

// Spacing
if (input.spacing) {
const isCollision = buildCollisionGuard('spacing', findings);
forEachLeaf(input.spacing, (name, raw) => {
if (isCollision(name)) return;

if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
spacing.set(name, resolved);
Expand All @@ -125,54 +134,27 @@ export class ModelHandler implements ModelSpec {
}, '', 0, findings, 'spacing');
}

// ── Phase 2: Resolve chained color references ──────────────────
// Iterate color entries that are still raw references and resolve them
if (input.colors) {
forEachLeaf(input.colors, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') {
colors.set(name, resolved as ResolvedColor);
symbolTable.set(`colors.${name}`, resolved);
}
}
});
}

// Resolve chained rounded references
if (input.rounded) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
resolved !== null &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'dimension'
) {
rounded.set(name, resolved as ResolvedDimension);
symbolTable.set(`rounded.${name}`, resolved);
}
}
});
}

// Resolve chained spacing references
if (input.spacing) {
forEachLeaf(input.spacing, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
resolved !== null &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'dimension'
) {
spacing.set(name, resolved as ResolvedDimension);
symbolTable.set(`spacing.${name}`, resolved);
}
}
});
// ── Phase 2: Resolve chained token references ──────────────────
// Iterate the symbol table directly (not re-walking raw input) so that
// Phase 1 collision decisions are never overwritten.
for (const [key, value] of symbolTable) {
if (typeof value !== 'string' || !isTokenReference(value)) continue;
const resolved = resolveReference(symbolTable, value.slice(1, -1), new Set());
if (resolved === null || typeof resolved !== 'object' || !('type' in resolved)) continue;

if (key.startsWith('colors.') && resolved.type === 'color') {
const name = key.slice('colors.'.length);
colors.set(name, resolved as ResolvedColor);
symbolTable.set(key, resolved);
} else if (key.startsWith('rounded.') && resolved.type === 'dimension') {
const name = key.slice('rounded.'.length);
rounded.set(name, resolved as ResolvedDimension);
symbolTable.set(key, resolved);
} else if (key.startsWith('spacing.') && resolved.type === 'dimension') {
const name = key.slice('spacing.'.length);
spacing.set(name, resolved as ResolvedDimension);
symbolTable.set(key, resolved);
}
}

// ── Phase 3: Build components ──────────────────────────────────
Expand Down Expand Up @@ -265,6 +247,45 @@ export class ModelHandler implements ModelSpec {

// ── Pure utility functions ─────────────────────────────────────────

/**
* Returns a predicate that detects token name collisions within a single
* token category (colors, rounded, spacing). Call once per category; the
* returned function tracks state via closure.
*
* Returns true (and pushes a finding) when the candidate name collides with
* an already-registered key, so callers can skip it with a simple `if
* (isCollision(name)) return;`.
*/
function buildCollisionGuard(
category: string,
findings: Finding[],
): (name: string) => boolean {
const seenKeys = new Set<string>();
const seenNormalized = new Map<string, string>();
return (name: string): boolean => {
const normalized = name.replace(/\./g, '-');
if (seenKeys.has(name)) {
findings.push({
severity: 'error',
path: `${category}.${name}`,
message: `Duplicate token path '${category}.${name}' detected.`,
});
return true;
}
if (seenNormalized.has(normalized)) {
findings.push({
severity: 'error',
path: `${category}.${name}`,
message: `Grouped ${category} token flattens to '${normalized}', which is already defined.`,
});
return true;
}
seenKeys.add(name);
seenNormalized.set(normalized, name);
return false;
};
}

/**
* Parse a CSS color string into a ResolvedColor with RGB + WCAG luminance.
*/
Expand Down