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
105 changes: 105 additions & 0 deletions packages/cli/src/linter/model/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,61 @@ describe('ModelHandler', () => {
expect(semitransparent?.a).toBeCloseTo(166 / 255, 5);
});

it('successfully parses nested color declarations (Issue #102)', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: '#fbfaf1',
dark: '#11140e'
}
}
}));

expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('background.light')).toBe(true);
expect(result.designSystem.colors.has('background.dark')).toBe(true);
expect(result.designSystem.colors.get('background.light')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.background.light')).toBe(true);
});

it('successfully parses 3-level nested color declarations', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: {
primary: '#fbfaf1',
secondary: '#f0f0f0'
}
}
}
}));

expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('background.light.primary')).toBe(true);
expect(result.designSystem.colors.has('background.light.secondary')).toBe(true);
expect(result.designSystem.colors.get('background.light.primary')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.background.light.primary')).toBe(true);
});

it('successfully parses 4-level nested color declarations', () => {
const result = handler.execute(makeParsed({
colors: {
theme: {
surface: {
background: {
base: '#fbfaf1'
}
}
}
}
}));

expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('theme.surface.background.base')).toBe(true);
expect(result.designSystem.colors.get('theme.surface.background.base')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.theme.surface.background.base')).toBe(true);
});

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 Expand Up @@ -204,6 +259,22 @@ describe('ModelHandler', () => {
expect(bg.hex).toBe('#647d66');
}
});

it('resolves references to nested colors', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: '#fbfaf1',
dark: '#11140e'
},
page: '{colors.background.light}'
}
}));

expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
const page = result.designSystem.colors.get('page');
expect(page?.hex).toBe('#fbfaf1');
});
});

// ── Cycle 12: Detect circular reference ───────────────────────────
Expand Down Expand Up @@ -560,4 +631,38 @@ describe('ModelHandler', () => {
expect(card?.properties.get('disabled') as unknown).toBe(false);
});
});

describe('token nesting depth limit', () => {
it('emits error when token nesting depth exceeds 20', () => {
// 22 levels: Level 1..21 are objects, Level 22 is a leaf.
// forEachLeaf will be called for Level 22 with depth 21.
let obj: any = '#ffffff';
for (let i = 22; i >= 1; i--) {
obj = { [`level${i}`]: obj };
}

const result = handler.execute(makeParsed({
colors: obj,
}));
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(true);
expect(result.findings.find((f) => f.message.includes('nesting depth'))?.path).toBe('colors');
});

it('allows nesting up to depth 20', () => {
// 21 levels: Level 1..20 are objects, Level 21 is a leaf.
// forEachLeaf will be called for Level 21 with depth 20.
let obj: any = '#ffffff';
for (let i = 21; i >= 1; i--) {
obj = { [`level${i}`]: obj };
}

const result = handler.execute(makeParsed({
colors: obj,
}));
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(false);
// Construct the expected path: level1.level2...level21
const path = Array.from({ length: 21 }, (_, i) => `level${i + 1}`).join('.');
expect(result.designSystem.colors.has(path)).toBe(true);
});
});
});
68 changes: 53 additions & 15 deletions packages/cli/src/linter/model/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import type {
import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts } from './spec.js';
import { parseCssColor } from './color-parser.js';

const MAX_REFERENCE_DEPTH = 10;
import {
MAX_REFERENCE_DEPTH,
MAX_TOKEN_NESTING_DEPTH,
} from '../spec-config.js';

const SCHEMA_KEY_SET: ReadonlySet<string> = new Set(SCHEMA_KEYS);

Expand All @@ -51,8 +54,8 @@ export class ModelHandler implements ModelSpec {
// ── Phase 1: Resolve primitive tokens ──────────────────────────
// Colors
if (input.colors) {
for (const [name, raw] of Object.entries(input.colors)) {
if (isTokenReference(raw)) {
forEachLeaf(input.colors, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
// Store raw reference for later resolution
symbolTable.set(`colors.${name}`, raw);
} else if (isValidColor(raw)) {
Expand All @@ -68,7 +71,7 @@ export class ModelHandler implements ModelSpec {
// Store as-is for fallback
symbolTable.set(`colors.${name}`, raw);
}
}
}, '', 0, findings, 'colors');
}

// Typography
Expand All @@ -82,7 +85,7 @@ export class ModelHandler implements ModelSpec {

// Rounded
if (input.rounded) {
for (const [name, raw] of Object.entries(input.rounded)) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string') {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
Expand All @@ -106,39 +109,39 @@ export class ModelHandler implements ModelSpec {
symbolTable.set(`rounded.${name}`, raw);
}
}
}
}, '', 0, findings, 'rounded');
}

// Spacing
if (input.spacing) {
for (const [name, raw] of Object.entries(input.spacing)) {
forEachLeaf(input.spacing, (name, raw) => {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
spacing.set(name, resolved);
symbolTable.set(`spacing.${name}`, resolved);
} else {
symbolTable.set(`spacing.${name}`, raw);
}
}
}, '', 0, findings, 'spacing');
}

// ── Phase 2: Resolve chained color references ──────────────────
// Iterate color entries that are still raw references and resolve them
if (input.colors) {
for (const [name, raw] of Object.entries(input.colors)) {
if (isTokenReference(raw)) {
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) {
for (const [name, raw] of Object.entries(input.rounded)) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
Expand All @@ -151,12 +154,12 @@ export class ModelHandler implements ModelSpec {
symbolTable.set(`rounded.${name}`, resolved);
}
}
}
});
}

// Resolve chained spacing references
if (input.spacing) {
for (const [name, raw] of Object.entries(input.spacing)) {
forEachLeaf(input.spacing, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
Expand All @@ -169,7 +172,7 @@ export class ModelHandler implements ModelSpec {
symbolTable.set(`spacing.${name}`, resolved);
}
}
}
});
}

// ── Phase 3: Build components ──────────────────────────────────
Expand Down Expand Up @@ -390,4 +393,39 @@ export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number {
const L1 = Math.max(a.luminance, b.luminance);
const L2 = Math.min(a.luminance, b.luminance);
return (L1 + 0.05) / (L2 + 0.05);
}

/**
* Recursively iterate over an object and call a function for each leaf node.
* Leaf node paths are dot-separated (e.g. "background.light").
*/
function forEachLeaf(
obj: Record<string, any>,
fn: (path: string, value: any) => void,
prefix = '',
depth = 0,
findings?: Finding[],
rootPath?: string
) {
if (depth > MAX_TOKEN_NESTING_DEPTH) {
if (findings && rootPath) {
// Check if we've already reported this rootPath to avoid spamming
if (!findings.some((f) => f.path === rootPath && f.message.includes('nesting depth'))) {
findings.push({
severity: 'error',
path: rootPath,
message: `Token nesting depth exceeds maximum allowed depth of ${MAX_TOKEN_NESTING_DEPTH}.`,
});
}
}
return;
}
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
forEachLeaf(value, fn, fullPath, depth + 1, findings, rootPath);
} else {
fn(fullPath, value);
}
}
}
3 changes: 2 additions & 1 deletion packages/cli/src/linter/model/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface ResolvedTypography {
fontVariation?: string | undefined;
}

export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string;
export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean;

// ── Re-exported from spec-config (single source of truth) ─────────
export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS;
Expand Down Expand Up @@ -98,6 +98,7 @@ export const ModelErrorCode = z.enum([
'UNRESOLVED_REFERENCE',
'CIRCULAR_REFERENCE',
'REFERENCE_TO_NON_PRIMITIVE',
'NESTING_DEPTH_EXCEEDED',
'UNKNOWN_ERROR',
]);

Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/linter/parser/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ export interface ParsedDesignSystem {
version?: string | undefined;
name?: string | undefined;
description?: string | undefined;
colors?: Record<string, string> | undefined;
typography?: Record<string, Record<string, string | number>> | undefined;
rounded?: Record<string, string> | undefined;
spacing?: Record<string, string> | undefined;
components?: Record<string, Record<string, string>> | undefined;
colors?: Record<string, any> | undefined;
typography?: Record<string, Record<string, any>> | undefined;
rounded?: Record<string, any> | undefined;
spacing?: Record<string, any> | undefined;
components?: Record<string, Record<string, any>> | undefined;
sourceMap: Map<string, SourceLocation>;
/** Markdown heading names found in the document (e.g., 'Colors', 'Typography') */
sections?: string[] | undefined;
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/linter/spec-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const PropertyDefSchema = z.object({

const ConfigSchema = z.object({
version: z.string(),
limits: z.object({
max_token_nesting_depth: z.number().default(20),
max_reference_depth: z.number().default(10),
}).default({}),
units: z.array(z.string()).min(1),
sections: z.array(z.object({
canonical: z.string(),
Expand Down Expand Up @@ -117,6 +121,10 @@ const config = getSpecConfig();
/** Current spec version. Appears in the schema and the front matter example. */
export const SPEC_VERSION = config.version;

/** Performance and safety limits for the model handler. */
export const MAX_TOKEN_NESTING_DEPTH = config.limits.max_token_nesting_depth;
export const MAX_REFERENCE_DEPTH = config.limits.max_reference_depth;

/** Units the spec formally supports for Dimension values. */
export const STANDARD_UNITS = config.units;
export type StandardUnit = (typeof STANDARD_UNITS)[number];
Expand Down Expand Up @@ -164,6 +172,8 @@ export const VALID_COMPONENT_SUB_TOKENS = COMPONENT_SUB_TOKENS.map(p => p.name);
/** All config values bundled as a single object for renderer injection. */
export interface SpecConfig {
SPEC_VERSION: typeof SPEC_VERSION;
MAX_TOKEN_NESTING_DEPTH: typeof MAX_TOKEN_NESTING_DEPTH;
MAX_REFERENCE_DEPTH: typeof MAX_REFERENCE_DEPTH;
STANDARD_UNITS: typeof STANDARD_UNITS;
SECTIONS: typeof SECTIONS;
TYPOGRAPHY_PROPERTIES: typeof TYPOGRAPHY_PROPERTIES;
Expand All @@ -176,6 +186,8 @@ export interface SpecConfig {
/** Build a SpecConfig from the module's exports. */
export const SPEC_CONFIG: SpecConfig = {
SPEC_VERSION,
MAX_TOKEN_NESTING_DEPTH,
MAX_REFERENCE_DEPTH,
STANDARD_UNITS,
SECTIONS,
TYPOGRAPHY_PROPERTIES,
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/linter/spec-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@

version: alpha

# Performance and safety limits for the model handler.
limits:
max_token_nesting_depth: 20
max_reference_depth: 10

units:
- px
- em
Expand Down