From a3d5058b496106b22b0145ce11dca06f5e76cfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= <258577966+voidborne-d@users.noreply.github.com> Date: Sat, 9 May 2026 19:00:49 +0800 Subject: [PATCH] fix(model): handle non-string YAML scalar component prop values without crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #75. #43 hardened the component property loop against bare numbers (`fontWeight: 600`), but only narrowed `typeof rawValue === 'number'`. Other non-string YAML scalars still crash: - bool (`disabled: false`) → `isValidColor` → `colorStr.trim()` TypeError - null (`metadata: ~`) → same path - sequence (`items: [a, b]`) → same path - mapping (`nested: { foo: x }`) → same path Reporter @cf62 in #75 hit the float case (`opacity: 0.9`), which actually no longer reproduces on main thanks to #43; the underlying class of bug, however, still surfaces for every other non-string scalar. Generalise the guard from `typeof === 'number'` to `typeof !== 'string'`. Any non-string value is stored as-is. The downstream rule layer already emits a 'not a recognized component sub-token' warning for these, matching the "continue validating the rest of the file" expected behaviour from the issue. Also adds 6 regression tests under "non-string component property values" (float / boolean / null / sequence / mapping / mixed-with-valid-string-props). Red-baseline: 5 of 6 fail without the handler.ts change; green with it. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/linter/model/handler.test.ts | 95 +++++++++++++++++++ packages/cli/src/linter/model/handler.ts | 13 +-- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index 0e0e05b..3918be7 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -506,4 +506,99 @@ describe('ModelHandler', () => { expect(btn?.properties.get('fontWeight')).toBe(600); }); }); + + // ── Fix #75: non-string YAML scalars crash model builder ────────── + describe('non-string component property values', () => { + it('does not crash when a property value is a bare float (regression for #42)', () => { + const result = handler.execute(makeParsed({ + colors: { primary: '#ff0000' }, + components: { + 'button': { + backgroundColor: '{colors.primary}', + opacity: 0.9 as unknown as string, + }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + const btn = result.designSystem.components.get('button'); + expect(btn?.properties.get('opacity')).toBe(0.9 as unknown as never); + }); + + it('does not crash when a property value is a YAML boolean', () => { + const result = handler.execute(makeParsed({ + components: { + 'button': { + disabled: false as unknown as string, + }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + const btn = result.designSystem.components.get('button'); + expect(btn?.properties.get('disabled')).toBe(false as unknown as never); + }); + + it('does not crash when a property value is YAML null', () => { + const result = handler.execute(makeParsed({ + components: { + 'button': { + metadata: null as unknown as string, + }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + const btn = result.designSystem.components.get('button'); + expect(btn?.properties.get('metadata')).toBeNull(); + }); + + it('does not crash when a property value is a YAML sequence', () => { + const result = handler.execute(makeParsed({ + components: { + 'button': { + items: ['a', 'b'] as unknown as string, + }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + const btn = result.designSystem.components.get('button'); + expect(btn?.properties.get('items')).toEqual(['a', 'b'] as unknown as never); + }); + + it('does not crash when a property value is a nested YAML mapping', () => { + const result = handler.execute(makeParsed({ + components: { + 'button': { + nested: { foo: 'bar' } as unknown as string, + }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + const btn = result.designSystem.components.get('button'); + expect(btn?.properties.get('nested')).toEqual({ foo: 'bar' } as unknown as never); + }); + + it('still resolves valid string token references and color/dimension props in same component', () => { + const result = handler.execute(makeParsed({ + colors: { primary: '#ff0000' }, + spacing: { md: '16px' }, + components: { + 'button': { + backgroundColor: '{colors.primary}', + padding: '{spacing.md}', + borderRadius: '4px', + opacity: 0.9 as unknown as string, + disabled: false as unknown as string, + }, + }, + })); + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + const btn = result.designSystem.components.get('button'); + expect(btn).toBeDefined(); + const bg = btn?.properties.get('backgroundColor'); + expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); + const padding = btn?.properties.get('padding'); + expect(typeof padding === 'object' && padding !== null && 'type' in padding && padding.type === 'dimension').toBe(true); + expect(btn?.properties.get('opacity')).toBe(0.9 as unknown as never); + expect(btn?.properties.get('disabled')).toBe(false as unknown as never); + }); + }); }); \ 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 caca7e4..ee39be6 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -177,12 +177,13 @@ export class ModelHandler implements ModelSpec { const unresolvedRefs: string[] = []; for (const [propName, rawValue] of Object.entries(props)) { - // Numeric values (e.g. fontWeight: 600, borderWidth: 1) are valid - // per spec and must be stored as-is, coercing to string first - // would cause isTokenReference / isValidColor to call .match() on - // a number and crash with "raw.match is not a function". - if (typeof rawValue === 'number') { - properties.set(propName, rawValue); + // YAML scalars that aren't strings (numbers, booleans, null) and + // non-scalar values (arrays, objects) must be stored as-is. + // Passing them to isTokenReference / isValidColor / isParseableDimension + // would crash inside .match() / .trim() with errors like + // "raw.match is not a function" or "colorStr.trim is not a function". + if (typeof rawValue !== 'string') { + properties.set(propName, rawValue as unknown as ResolvedValue); } else if (isTokenReference(rawValue)) { const refPath = rawValue.slice(1, -1); const resolved = resolveReference(symbolTable, refPath, new Set());