Skip to content

Commit edaf16a

Browse files
committed
fix: support numeric/boolean properties and limit nesting depth
1 parent f4fe942 commit edaf16a

3 files changed

Lines changed: 62 additions & 6 deletions

File tree

packages/cli/src/linter/model/handler.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,4 +631,38 @@ describe('ModelHandler', () => {
631631
expect(card?.properties.get('disabled') as unknown).toBe(false);
632632
});
633633
});
634+
635+
describe('token nesting depth limit', () => {
636+
it('emits error when token nesting depth exceeds 20', () => {
637+
// 22 levels: Level 1..21 are objects, Level 22 is a leaf.
638+
// forEachLeaf will be called for Level 22 with depth 21.
639+
let obj: any = '#ffffff';
640+
for (let i = 22; i >= 1; i--) {
641+
obj = { [`level${i}`]: obj };
642+
}
643+
644+
const result = handler.execute(makeParsed({
645+
colors: obj,
646+
}));
647+
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(true);
648+
expect(result.findings.find((f) => f.message.includes('nesting depth'))?.path).toBe('colors');
649+
});
650+
651+
it('allows nesting up to depth 20', () => {
652+
// 21 levels: Level 1..20 are objects, Level 21 is a leaf.
653+
// forEachLeaf will be called for Level 21 with depth 20.
654+
let obj: any = '#ffffff';
655+
for (let i = 21; i >= 1; i--) {
656+
obj = { [`level${i}`]: obj };
657+
}
658+
659+
const result = handler.execute(makeParsed({
660+
colors: obj,
661+
}));
662+
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(false);
663+
// Construct the expected path: level1.level2...level21
664+
const path = Array.from({ length: 21 }, (_, i) => `level${i + 1}`).join('.');
665+
expect(result.designSystem.colors.has(path)).toBe(true);
666+
});
667+
});
634668
});

packages/cli/src/linter/model/handler.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { isValidColor, isParseableDimension, isTokenReference, parseDimensionPar
2929
import { parseCssColor } from './color-parser.js';
3030

3131
const MAX_REFERENCE_DEPTH = 10;
32+
const MAX_TOKEN_NESTING_DEPTH = 20;
3233

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

@@ -68,7 +69,7 @@ export class ModelHandler implements ModelSpec {
6869
// Store as-is for fallback
6970
symbolTable.set(`colors.${name}`, raw);
7071
}
71-
});
72+
}, '', 0, findings, 'colors');
7273
}
7374

7475
// Typography
@@ -106,7 +107,7 @@ export class ModelHandler implements ModelSpec {
106107
symbolTable.set(`rounded.${name}`, raw);
107108
}
108109
}
109-
});
110+
}, '', 0, findings, 'rounded');
110111
}
111112

112113
// Spacing
@@ -119,7 +120,7 @@ export class ModelHandler implements ModelSpec {
119120
} else {
120121
symbolTable.set(`spacing.${name}`, raw);
121122
}
122-
});
123+
}, '', 0, findings, 'spacing');
123124
}
124125

125126
// ── Phase 2: Resolve chained color references ──────────────────
@@ -396,11 +397,31 @@ export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number {
396397
* Recursively iterate over an object and call a function for each leaf node.
397398
* Leaf node paths are dot-separated (e.g. "background.light").
398399
*/
399-
function forEachLeaf(obj: Record<string, any>, fn: (path: string, value: any) => void, prefix = '') {
400+
function forEachLeaf(
401+
obj: Record<string, any>,
402+
fn: (path: string, value: any) => void,
403+
prefix = '',
404+
depth = 0,
405+
findings?: Finding[],
406+
rootPath?: string
407+
) {
408+
if (depth > MAX_TOKEN_NESTING_DEPTH) {
409+
if (findings && rootPath) {
410+
// Check if we've already reported this rootPath to avoid spamming
411+
if (!findings.some((f) => f.path === rootPath && f.message.includes('nesting depth'))) {
412+
findings.push({
413+
severity: 'error',
414+
path: rootPath,
415+
message: `Token nesting depth exceeds maximum allowed depth of ${MAX_TOKEN_NESTING_DEPTH}.`,
416+
});
417+
}
418+
}
419+
return;
420+
}
400421
for (const [key, value] of Object.entries(obj)) {
401422
const fullPath = prefix ? `${prefix}.${key}` : key;
402423
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
403-
forEachLeaf(value, fn, fullPath);
424+
forEachLeaf(value, fn, fullPath, depth + 1, findings, rootPath);
404425
} else {
405426
fn(fullPath, value);
406427
}

packages/cli/src/linter/model/spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export interface ResolvedTypography {
6161
fontVariation?: string | undefined;
6262
}
6363

64-
export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string;
64+
export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean;
6565

6666
// ── Re-exported from spec-config (single source of truth) ─────────
6767
export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS;
@@ -98,6 +98,7 @@ export const ModelErrorCode = z.enum([
9898
'UNRESOLVED_REFERENCE',
9999
'CIRCULAR_REFERENCE',
100100
'REFERENCE_TO_NON_PRIMITIVE',
101+
'NESTING_DEPTH_EXCEEDED',
101102
'UNKNOWN_ERROR',
102103
]);
103104

0 commit comments

Comments
 (0)