diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index f51683e..191e31a 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -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' }, @@ -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 ─────────────────────────── @@ -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); + }); + }); }); \ No newline at end of file diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index 146b534..1513c7a 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -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 = new Set(SCHEMA_KEYS); @@ -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)) { @@ -68,7 +71,7 @@ export class ModelHandler implements ModelSpec { // Store as-is for fallback symbolTable.set(`colors.${name}`, raw); } - } + }, '', 0, findings, 'colors'); } // Typography @@ -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); @@ -106,12 +109,12 @@ 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); @@ -119,26 +122,26 @@ export class ModelHandler implements ModelSpec { } 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 ( @@ -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 ( @@ -169,7 +172,7 @@ export class ModelHandler implements ModelSpec { symbolTable.set(`spacing.${name}`, resolved); } } - } + }); } // ── Phase 3: Build components ────────────────────────────────── @@ -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, + 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); + } + } } \ No newline at end of file diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index 2211c7f..d8dcdce 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -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; @@ -98,6 +98,7 @@ export const ModelErrorCode = z.enum([ 'UNRESOLVED_REFERENCE', 'CIRCULAR_REFERENCE', 'REFERENCE_TO_NON_PRIMITIVE', + 'NESTING_DEPTH_EXCEEDED', 'UNKNOWN_ERROR', ]); diff --git a/packages/cli/src/linter/parser/spec.ts b/packages/cli/src/linter/parser/spec.ts index 663e07e..72c26d5 100644 --- a/packages/cli/src/linter/parser/spec.ts +++ b/packages/cli/src/linter/parser/spec.ts @@ -42,11 +42,11 @@ export interface ParsedDesignSystem { version?: string | undefined; name?: string | undefined; description?: string | undefined; - colors?: Record | undefined; - typography?: Record> | undefined; - rounded?: Record | undefined; - spacing?: Record | undefined; - components?: Record> | undefined; + colors?: Record | undefined; + typography?: Record> | undefined; + rounded?: Record | undefined; + spacing?: Record | undefined; + components?: Record> | undefined; sourceMap: Map; /** Markdown heading names found in the document (e.g., 'Colors', 'Typography') */ sections?: string[] | undefined; diff --git a/packages/cli/src/linter/spec-config.ts b/packages/cli/src/linter/spec-config.ts index e6cbb68..b146a47 100644 --- a/packages/cli/src/linter/spec-config.ts +++ b/packages/cli/src/linter/spec-config.ts @@ -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(), @@ -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]; @@ -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; @@ -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, diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index d00c233..67dfa14 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -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