From 3b65728e35242edb14ac0dac0a573d981b775a38 Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 10 Apr 2026 18:22:48 -0400 Subject: [PATCH 1/6] refactor(linter): redesign public API and fix test IDE errors --- .gitignore | 5 + bun.lock | 60 ++++ package.json | 16 ++ packages/linter/package.json | 27 ++ packages/linter/src/fixture.test.ts | 47 ++++ packages/linter/src/fixtures/DESIGN-test.md | 183 ++++++++++++ packages/linter/src/index.test.ts | 114 ++++++++ packages/linter/src/index.ts | 32 +++ packages/linter/src/lint.ts | 61 ++++ packages/linter/src/linter/handler.test.ts | 238 ++++++++++++++++ packages/linter/src/linter/handler.ts | 21 ++ .../src/linter/rules/broken-ref.test.ts | 23 ++ .../linter/src/linter/rules/broken-ref.ts | 32 +++ .../src/linter/rules/contrast-ratio.test.ts | 29 ++ .../linter/src/linter/rules/contrast-ratio.ts | 39 +++ packages/linter/src/linter/rules/index.ts | 32 +++ .../src/linter/rules/invalid-color.test.ts | 24 ++ .../linter/src/linter/rules/invalid-color.ts | 23 ++ .../src/linter/rules/missing-primary.test.ts | 23 ++ .../src/linter/rules/missing-primary.ts | 16 ++ .../src/linter/rules/missing-sections.test.ts | 32 +++ .../src/linter/rules/missing-sections.ts | 24 ++ .../linter/rules/non-standard-unit.test.ts | 29 ++ .../src/linter/rules/non-standard-unit.ts | 50 ++++ .../src/linter/rules/orphaned-tokens.test.ts | 23 ++ .../src/linter/rules/orphaned-tokens.ts | 35 +++ .../linter/src/linter/rules/test-helpers.ts | 16 ++ .../src/linter/rules/token-summary.test.ts | 24 ++ .../linter/src/linter/rules/token-summary.ts | 23 ++ .../linter/src/linter/rules/types.test.ts | 16 ++ packages/linter/src/linter/rules/types.ts | 5 + packages/linter/src/linter/runner.test.ts | 71 +++++ packages/linter/src/linter/runner.ts | 45 +++ packages/linter/src/linter/spec.ts | 47 ++++ packages/linter/src/model/handler.test.ts | 162 +++++++++++ packages/linter/src/model/handler.ts | 260 ++++++++++++++++++ packages/linter/src/model/spec.test.ts | 83 ++++++ packages/linter/src/model/spec.ts | 177 ++++++++++++ packages/linter/src/parser/handler.test.ts | 127 +++++++++ packages/linter/src/parser/handler.ts | 183 ++++++++++++ packages/linter/src/parser/spec.test.ts | 19 ++ packages/linter/src/parser/spec.ts | 53 ++++ packages/linter/src/tailwind/handler.test.ts | 90 ++++++ packages/linter/src/tailwind/handler.ts | 66 +++++ packages/linter/src/tailwind/spec.ts | 19 ++ packages/linter/tsconfig.build.json | 7 + packages/linter/tsconfig.json | 11 + tsconfig.base.json | 14 + turbo.json | 13 + 49 files changed, 2769 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/linter/package.json create mode 100644 packages/linter/src/fixture.test.ts create mode 100644 packages/linter/src/fixtures/DESIGN-test.md create mode 100644 packages/linter/src/index.test.ts create mode 100644 packages/linter/src/index.ts create mode 100644 packages/linter/src/lint.ts create mode 100644 packages/linter/src/linter/handler.test.ts create mode 100644 packages/linter/src/linter/handler.ts create mode 100644 packages/linter/src/linter/rules/broken-ref.test.ts create mode 100644 packages/linter/src/linter/rules/broken-ref.ts create mode 100644 packages/linter/src/linter/rules/contrast-ratio.test.ts create mode 100644 packages/linter/src/linter/rules/contrast-ratio.ts create mode 100644 packages/linter/src/linter/rules/index.ts create mode 100644 packages/linter/src/linter/rules/invalid-color.test.ts create mode 100644 packages/linter/src/linter/rules/invalid-color.ts create mode 100644 packages/linter/src/linter/rules/missing-primary.test.ts create mode 100644 packages/linter/src/linter/rules/missing-primary.ts create mode 100644 packages/linter/src/linter/rules/missing-sections.test.ts create mode 100644 packages/linter/src/linter/rules/missing-sections.ts create mode 100644 packages/linter/src/linter/rules/non-standard-unit.test.ts create mode 100644 packages/linter/src/linter/rules/non-standard-unit.ts create mode 100644 packages/linter/src/linter/rules/orphaned-tokens.test.ts create mode 100644 packages/linter/src/linter/rules/orphaned-tokens.ts create mode 100644 packages/linter/src/linter/rules/test-helpers.ts create mode 100644 packages/linter/src/linter/rules/token-summary.test.ts create mode 100644 packages/linter/src/linter/rules/token-summary.ts create mode 100644 packages/linter/src/linter/rules/types.test.ts create mode 100644 packages/linter/src/linter/rules/types.ts create mode 100644 packages/linter/src/linter/runner.test.ts create mode 100644 packages/linter/src/linter/runner.ts create mode 100644 packages/linter/src/linter/spec.ts create mode 100644 packages/linter/src/model/handler.test.ts create mode 100644 packages/linter/src/model/handler.ts create mode 100644 packages/linter/src/model/spec.test.ts create mode 100644 packages/linter/src/model/spec.ts create mode 100644 packages/linter/src/parser/handler.test.ts create mode 100644 packages/linter/src/parser/handler.ts create mode 100644 packages/linter/src/parser/spec.test.ts create mode 100644 packages/linter/src/parser/spec.ts create mode 100644 packages/linter/src/tailwind/handler.test.ts create mode 100644 packages/linter/src/tailwind/handler.ts create mode 100644 packages/linter/src/tailwind/spec.ts create mode 100644 packages/linter/tsconfig.build.json create mode 100644 packages/linter/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5c4f0efa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.local +bun.lockb diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..f1416b85 --- /dev/null +++ b/bun.lock @@ -0,0 +1,60 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "design-monorepo", + "devDependencies": { + "turbo": "latest", + "typescript": "latest", + }, + }, + "packages/linter": { + "name": "@google/design.md", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.7.1", + "zod": "^3.24.0", + }, + "devDependencies": { + "@types/bun": "^1.3.11", + "@types/node": "^20.11.24", + }, + }, + }, + "packages": { + "@google/design.md": ["@google/design.md@workspace:packages/linter"], + + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..af3f7556 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "design-monorepo", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "turbo build", + "test": "turbo test", + "lint": "turbo lint" + }, + "devDependencies": { + "turbo": "latest", + "typescript": "latest" + } +} diff --git a/packages/linter/package.json b/packages/linter/package.json new file mode 100644 index 00000000..8389f82e --- /dev/null +++ b/packages/linter/package.json @@ -0,0 +1,27 @@ +{ + "name": "@google/design.md", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "bun test src", + "lint": "echo 'no linter configured yet'" + }, + "dependencies": { + "yaml": "^2.7.1", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/bun": "^1.3.11", + "@types/node": "^20.11.24" + } +} \ No newline at end of file diff --git a/packages/linter/src/fixture.test.ts b/packages/linter/src/fixture.test.ts new file mode 100644 index 00000000..5169ca70 --- /dev/null +++ b/packages/linter/src/fixture.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'bun:test'; +import { lint } from './index.js'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +describe('Fixture Test', () => { + it('processes DESIGN-test.md', () => { + // Use import.meta.dir to get the current directory in Bun ESM + const path = join(import.meta.dir, 'fixtures', 'DESIGN-test.md'); + const content = readFileSync(path, 'utf-8'); + + const result = lint(content); + + // Basic state assertions + expect(result.designSystem.name).toBe('Pacific Mint Dental'); + expect(result.designSystem.colors.size).toBeGreaterThan(0); + expect(result.designSystem.typography.size).toBeGreaterThan(0); + + // Check a specific color + const surface = result.designSystem.colors.get('surface'); + expect(surface).toBeDefined(); + expect(surface?.hex).toBe('#f9f9ff'); + + // Check a typography scale + const displayLg = result.designSystem.typography.get('display-lg'); + expect(displayLg).toBeDefined(); + expect(displayLg?.fontFamily).toBe('Manrope'); + expect(displayLg?.fontSize?.value).toBe(48); + expect(displayLg?.fontSize?.unit).toBe('px'); + + // fontWeight: '700' (string) is not parsed — spec requires number + expect(displayLg?.fontWeight).toBeUndefined(); + // letterSpacing: -0.02em is parsed (model is generous) but flagged by linter + expect(displayLg?.letterSpacing).toBeDefined(); + expect(displayLg?.letterSpacing?.value).toBe(-0.02); + expect(displayLg?.letterSpacing?.unit).toBe('em'); + + // Check lint results — should have W4 warning for em units + const nonStdWarnings = result.diagnostics.filter( + (d: { severity: string; message: string }) => d.severity === 'warning' && d.message.includes('non-standard') + ); + expect(nonStdWarnings.length).toBeGreaterThan(0); + + // We expect at least the summary info + expect(result.summary.infos).toBeGreaterThan(0); + }); +}); diff --git a/packages/linter/src/fixtures/DESIGN-test.md b/packages/linter/src/fixtures/DESIGN-test.md new file mode 100644 index 00000000..26887ee4 --- /dev/null +++ b/packages/linter/src/fixtures/DESIGN-test.md @@ -0,0 +1,183 @@ +--- +name: Pacific Mint Dental +colors: + surface: '#f9f9ff' + surface-dim: '#cfdaf1' + surface-bright: '#f9f9ff' + surface-container-lowest: '#ffffff' + surface-container-low: '#f0f3ff' + surface-container: '#e7eeff' + surface-container-high: '#dee8ff' + surface-container-highest: '#d8e3fa' + on-surface: '#111c2c' + on-surface-variant: '#3d4945' + inverse-surface: '#263142' + inverse-on-surface: '#ebf1ff' + outline: '#6d7a75' + outline-variant: '#bcc9c4' + surface-tint: '#006b5a' + primary: '#006b5a' + on-primary: '#ffffff' + primary-container: '#4cbfa6' + on-primary-container: '#004a3d' + inverse-primary: '#69dabf' + secondary: '#075fab' + on-secondary: '#ffffff' + secondary-container: '#70aeff' + on-secondary-container: '#004077' + tertiary: '#59605e' + on-tertiary: '#ffffff' + tertiary-container: '#a7aeac' + on-tertiary-container: '#3b4240' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#87f6db' + primary-fixed-dim: '#69dabf' + on-primary-fixed: '#00201a' + on-primary-fixed-variant: '#005143' + secondary-fixed: '#d4e3ff' + secondary-fixed-dim: '#a4c9ff' + on-secondary-fixed: '#001c39' + on-secondary-fixed-variant: '#004884' + tertiary-fixed: '#dde4e1' + tertiary-fixed-dim: '#c1c8c5' + on-tertiary-fixed: '#161d1b' + on-tertiary-fixed-variant: '#414846' + background: '#f9f9ff' + on-background: '#111c2c' + surface-variant: '#d8e3fa' +typography: + display-lg: + fontFamily: Manrope + fontSize: 48px + fontWeight: '700' + lineHeight: 56px + letterSpacing: -0.02em + headline-lg: + fontFamily: Manrope + fontSize: 32px + fontWeight: '600' + lineHeight: 40px + letterSpacing: -0.01em + headline-md: + fontFamily: Manrope + fontSize: 24px + fontWeight: '600' + lineHeight: 32px + body-lg: + fontFamily: Inter + fontSize: 18px + fontWeight: '400' + lineHeight: 28px + body-md: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + label-lg: + fontFamily: Inter + fontSize: 14px + fontWeight: '600' + lineHeight: 20px + letterSpacing: 0.01em + label-sm: + fontFamily: Inter + fontSize: 12px + fontWeight: '500' + lineHeight: 16px +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + unit: 8px + container-max: 1200px + gutter: 24px + margin-mobile: 16px + margin-desktop: 40px +--- + +## Brand & Style + +The design system is anchored in the concept of "Clinical Serenity." Aimed at urban professionals in Seattle, the aesthetic balances the precision of modern dentistry with the calming atmosphere of a high-end wellness studio. The personality is approachable yet authoritative, designed to reduce patient anxiety through visual clarity and soft interactions. + +The design style utilizes a **Modern Corporate** foundation with **Soft-Minimalist** overlays. It avoids the harsh sterility of traditional medical interfaces by using generous whitespace, translucent layers, and a palette inspired by the Pacific Northwest’s natural light and water. The UI should feel airy and breathable, prioritizing ease of navigation and a sense of cleanliness. + +## Colors + +The palette is centered on a "Calming Mint" primary tone that signals health and freshness. This is paired with a "Soft Sky Blue" secondary to reinforce feelings of trust and stability. + +- **Primary:** A vibrant yet desaturated mint green used for calls to action and key brand indicators. + +- **Secondary:** A soft blue used for supportive information, secondary actions, and navigational accents. + +- **Surface:** Crisp white is the dominant background color to maintain a clinical standard of cleanliness. + +- **Accents:** Tertiary mint-whites are used for large background sections to soften the contrast against pure white. + +- **Typography:** Deep slate grays replace pure black to ensure the interface remains soft and legible without being aggressive. + +## Typography + +This design system utilizes a dual-font strategy to balance character with utility. + +**Manrope** is used for headlines. Its geometric yet slightly rounded apertures provide a contemporary, friendly look that mirrors the "roundedness" of the brand's shape language. + +**Inter** is used for all body copy and functional labels. Chosen for its exceptional legibility in clinical contexts, it ensures that medical information and appointment details are communicated with absolute clarity. Hierarchy is established through weight shifts (Medium to Semibold) rather than dramatic size changes to maintain a calm, steady rhythm. + +## Layout & Spacing + +The layout follows a **Fixed Grid** philosophy for desktop views, centering content within a 1200px container to create an organized, professional feel. On smaller screens, the system transitions to a fluid model with generous margins. + +The rhythm is built on a strictly enforced 8px base unit. + +- Use **24px (3 units)** for standard gutters and element spacing. +- Use **48px-64px (6-8 units)** for vertical section spacing to maintain an "airy" and unhurried feel. +- Elements should be aligned to a 12-column grid to ensure information-heavy pages (like dental history or treatment plans) remain structured and digestible. + +## Elevation & Depth + +To convey a sense of modern care, the design system utilizes **Ambient Shadows** and **Tonal Layers**. Depth is used sparingly to signify interactivity and importance. + +- **Level 1 (Base):** Crisp white or tertiary-mint backgrounds. +- **Level 2 (Cards/Widgets):** Subtle 1px borders in a light gray-blue or a very soft, diffused shadow (15% opacity primary color tint) to lift the element slightly from the background. +- **Level 3 (Modals/Popovers):** Higher diffusion shadows with no blur-offset, creating a "glow" effect that feels more like light than a physical shadow. + +Avoid heavy blacks or harsh dropshadows. All depth should feel "feathered" and light, as if diffused through a soft-box. + +## Shapes + +The shape language is defined by **Moderate Roundedness**. This approach softens the "sharpness" associated with dental tools and clinical environments, replacing it with a comforting, organic feel. + +- **Primary containers:** Use a 12px to 16px corner radius. +- **Buttons and Inputs:** Use a consistent 8px radius to feel modern but structured. +- **Small elements (Tags/Chips):** Can utilize pill-shapes (fully rounded) to differentiate them from functional inputs. + +Consistent radii across all components ensure the interface feels cohesive and intentionally designed. + +## Components + +### Buttons + +Primary buttons are solid Mint Green with white text and 8px rounded corners. Secondary buttons use a "Soft Blue" outline or ghost style. Hover states should involve a subtle scale-up (1.02x) rather than a dramatic color change to keep the interaction gentle. + +### Input Fields + +Inputs feature a light gray-blue border that transitions to the Primary Mint color on focus. Labels are always positioned above the field for maximum accessibility. Validation states (error/success) should use soft, desaturated versions of red and green to avoid alarming the user. + +### Calendar Widget + +The signature component of this design system. It utilizes a soft-shadowed card (Level 2 elevation) with high-contrast dates. Selected dates are highlighted with a Primary Mint circle. The header navigation (Month/Year) uses the secondary Sky Blue to provide clear visual separation from the functional grid. + +### Cards & Appointment Summaries + +Cards use a Level 1 elevation (subtle border) and generous internal padding (24px). They are used to group treatment steps or upcoming appointments, creating a "tiled" look that organizes the user's health journey. + +### Progress Indicators + +Thin, horizontal bars using a Mint Green fill on a light mint track, used to show treatment plan completion or booking steps. diff --git a/packages/linter/src/index.test.ts b/packages/linter/src/index.test.ts new file mode 100644 index 00000000..c21c732e --- /dev/null +++ b/packages/linter/src/index.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'bun:test'; +import { lint } from './index.js'; + +describe('Integration: full pipeline', () => { + it('processes a frontmatter DESIGN.md through the complete pipeline', () => { + const content = `--- +name: Kindred Spirit +description: Describes the design system for a pet care assistant. + +colors: + primary: "#647D66" + secondary: "#A3B8A5" + +typography: + headline-lg: + fontFamily: Google Sans Display + fontSize: 42px + fontWeight: 500 + lineHeight: 50px + letterSpacing: 1.2px + body-lg: + fontFamily: Roboto + fontSize: 14px + fontWeight: 400 + lineHeight: 20px + letterSpacing: 1.2px + +rounded: + regular: 4px + lg: 8px + xl: 12px + full: 9999px + +spacing: + gutter-s: 8px + gutter-l: 16px +--- + +# Kindred Spirit Design System + +The palette uses a deep "Evergreen" primary for health-sector credibility. +`; + + const result = lint(content); + + // ── State assertions ──────────────────────────────────────────── + expect(result.designSystem.colors.size).toBe(2); + expect(result.designSystem.typography.size).toBe(2); + expect(result.designSystem.rounded.size).toBe(4); + expect(result.designSystem.spacing.size).toBe(2); + expect(result.designSystem.name).toBe('Kindred Spirit'); + + // ── Lint assertions ───────────────────────────────────────────── + expect(result.summary.errors).toBe(0); + // Should have at least the info summary + expect(result.summary.infos).toBeGreaterThan(0); + + // ── Tailwind assertions ───────────────────────────────────────── + expect(result.tailwindConfig.theme.extend.colors?.['primary']).toBe('#647d66'); + expect(result.tailwindConfig.theme.extend.fontFamily?.['headline-lg']).toContain('Google Sans Display'); + expect(result.tailwindConfig.theme.extend.borderRadius?.['full']).toBe('9999px'); + expect(result.tailwindConfig.theme.extend.spacing?.['gutter-s']).toBe('8px'); + }); + + it('processes a code-block DESIGN.md with components', () => { + const content = `# Kindred Spirit + +\`\`\`yaml +colors: + primary: "#647D66" + white: "#ffffff" +\`\`\` + +\`\`\`yaml +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.white}" + rounded: 8px + padding: 12px +\`\`\` +`; + + const result = lint(content); + + expect(result.designSystem.colors.size).toBe(2); + expect(result.designSystem.components.size).toBe(1); + expect(result.summary.errors).toBe(0); + + // The component should have resolved backgroundColor to the primary color + const btn = result.designSystem.components.get('button-primary'); + expect(btn).toBeDefined(); + const bg = btn?.properties.get('backgroundColor'); + expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); + }); + + it('correctly detects errors in a broken DESIGN.md', () => { + const content = `--- +colors: + primary: "#647D66" + bad-color: red + +components: + card: + backgroundColor: "{colors.nonexistent}" + textColor: "#ffffff" +---`; + + const result = lint(content); + + // Should have errors: invalid color + broken reference + expect(result.summary.errors).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/linter/src/index.ts b/packages/linter/src/index.ts new file mode 100644 index 00000000..0faaec8f --- /dev/null +++ b/packages/linter/src/index.ts @@ -0,0 +1,32 @@ +// ── Primary API ──────────────────────────────────────────────────── +export { lint } from './lint.js'; +export type { LintReport, LintOptions } from './lint.js'; + +// ── Result types ─────────────────────────────────────────────────── +export type { + DesignSystemState, + ResolvedColor, + ResolvedDimension, + ResolvedTypography, + ResolvedValue, + ComponentDef, +} from './model/spec.js'; +export type { Diagnostic, Severity } from './linter/spec.js'; +export type { TailwindConfig } from './tailwind/spec.js'; + +// ── Advanced linting ─────────────────────────────────────────────── +export { runLinter, preEvaluate } from './linter/runner.js'; +export { DEFAULT_RULES } from './linter/rules/index.js'; +export type { LintRule } from './linter/rules/types.js'; +export type { GradedTokenEdits, TokenEditEntry } from './linter/spec.js'; +export { + invalidColor, + brokenRef, + missingPrimary, + contrastCheck, + orphanedTokens, + nonStandardUnit, + tokenSummary, + missingSections, +} from './linter/rules/index.js'; +export { contrastRatio } from './model/handler.js'; diff --git a/packages/linter/src/lint.ts b/packages/linter/src/lint.ts new file mode 100644 index 00000000..44f4c11a --- /dev/null +++ b/packages/linter/src/lint.ts @@ -0,0 +1,61 @@ +import { ParserHandler } from './parser/handler.js'; +import { ModelHandler } from './model/handler.js'; +import { runLinter } from './linter/runner.js'; +import { TailwindEmitterHandler } from './tailwind/handler.js'; +import type { DesignSystemState } from './model/spec.js'; +import type { Diagnostic } from './linter/spec.js'; +import type { LintRule } from './linter/rules/types.js'; +import type { TailwindConfig } from './tailwind/spec.js'; + +export interface LintOptions { + /** Custom lint rules. Defaults to DEFAULT_RULES if omitted. */ + rules?: LintRule[]; +} + +export interface LintReport { + /** The fully resolved design system model. */ + designSystem: DesignSystemState; + /** All diagnostic findings from the linter. */ + diagnostics: Diagnostic[]; + /** Aggregate counts by severity. */ + summary: { errors: number; warnings: number; infos: number }; + /** Generated Tailwind CSS theme configuration. */ + tailwindConfig: TailwindConfig; +} + +/** + * Lint a DESIGN.md document. + * + * Parses the markdown, resolves all design tokens into a typed model, + * runs lint rules, and generates a Tailwind CSS theme configuration. + * + * @param content - Raw DESIGN.md content (markdown with YAML frontmatter or code blocks) + * @param options - Optional configuration (custom rules, etc.) + * @returns A LintReport with the resolved design system, diagnostics, and Tailwind config + * @throws If parsing or model resolution fails unrecoverably + */ +export function lint(content: string, options?: LintOptions): LintReport { + const parser = new ParserHandler(); + const model = new ModelHandler(); + const tailwind = new TailwindEmitterHandler(); + + const parseResult = parser.execute({ content }); + if (!parseResult.success) { + throw new Error(`Parse failed: ${parseResult.error.message}`); + } + + const modelResult = model.execute(parseResult.data); + if (!modelResult.success) { + throw new Error(`Model build failed: ${modelResult.error.message}`); + } + + const lintResult = runLinter(modelResult.data, options?.rules); + const tailwindConfig = tailwind.execute(modelResult.data); + + return { + designSystem: modelResult.data, + diagnostics: lintResult.diagnostics, + summary: lintResult.summary, + tailwindConfig: tailwindConfig, + }; +} diff --git a/packages/linter/src/linter/handler.test.ts b/packages/linter/src/linter/handler.test.ts new file mode 100644 index 00000000..f80319f1 --- /dev/null +++ b/packages/linter/src/linter/handler.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'bun:test'; +import { LinterHandler } from './handler.js'; +import { ModelHandler } from '../model/handler.js'; +import type { ParsedDesignSystem } from '../parser/spec.js'; +import type { DesignSystemState } from '../model/spec.js'; +import type { Diagnostic } from './spec.js'; + +const linter = new LinterHandler(); +const modelHandler = new ModelHandler(); + +/** Helper: parse → build model → return state */ +function buildState(overrides: Partial = {}): DesignSystemState { + const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; + const result = modelHandler.execute(parsed); + if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); + return result.data; +} + +describe('LinterHandler', () => { + // ── Cycle 14: E1 — Invalid color emits error ───────────────────── + describe('E1: invalid color format', () => { + it('emits error for non-hex color values', () => { + const state = buildState({ colors: { accent: 'red' } }); + const result = linter.lint(state); + const errors = result.diagnostics.filter((d: Diagnostic) => d.severity === 'error'); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((d: Diagnostic) => d.message.includes('red') && d.message.includes('hex'))).toBe(true); + }); + }); + + // ── Cycle 15: E3 — Broken reference emits error ────────────────── + describe('E3: broken token reference', () => { + it('emits error when a component references a non-existent token', () => { + const state = buildState({ + colors: { primary: '#ff0000' }, + components: { + 'button': { + backgroundColor: '{colors.nonexistent}', + }, + }, + }); + const result = linter.lint(state); + const errors = result.diagnostics.filter((d: Diagnostic) => d.severity === 'error'); + expect(errors.some((d: Diagnostic) => d.message.includes('does not resolve'))).toBe(true); + }); + }); + + // ── Cycle 16: E4 — Circular reference emits error ──────────────── + describe('E4: circular reference', () => { + it('emits error when circular references are detected', () => { + const state = buildState({ + colors: { + 'a': '{colors.b}' as string, + 'b': '{colors.a}' as string, + }, + components: { + 'card': { + backgroundColor: '{colors.a}', + }, + }, + }); + const result = linter.lint(state); + const errors = result.diagnostics.filter((d: Diagnostic) => d.severity === 'error'); + expect(errors.some((d: Diagnostic) => d.message.toLowerCase().includes('unresolved') || d.message.toLowerCase().includes('resolve'))).toBe(true); + }); + }); + + // ── Cycle 17: W1 — Missing primary emits warning ───────────────── + describe('W1: missing primary color', () => { + it('emits warning when no primary color is defined', () => { + const state = buildState({ + colors: { accent: '#ff0000' }, + }); + const result = linter.lint(state); + const warnings = result.diagnostics.filter((d: Diagnostic) => d.severity === 'warning'); + expect(warnings.some((d: Diagnostic) => d.message.includes('primary'))).toBe(true); + }); + + it('does NOT emit warning when primary color IS defined', () => { + const state = buildState({ + colors: { primary: '#ff0000' }, + }); + const result = linter.lint(state); + const warnings = result.diagnostics.filter((d: Diagnostic) => d.severity === 'warning' && d.message.includes('primary')); + expect(warnings.length).toBe(0); + }); + }); + + // ── Cycle 18: W2 — Low contrast ratio emits warning ────────────── + describe('W2: WCAG contrast failure', () => { + it('emits warning for low contrast backgroundColor/textColor pair', () => { + const state = buildState({ + colors: { + 'yellow': '#ffff00', + 'white': '#ffffff', + }, + components: { + 'button-bad': { + backgroundColor: '{colors.yellow}', + textColor: '{colors.white}', + }, + }, + }); + const result = linter.lint(state); + const warnings = result.diagnostics.filter((d: Diagnostic) => d.severity === 'warning'); + expect(warnings.some((d: Diagnostic) => d.message.includes('contrast'))).toBe(true); + }); + + it('does NOT emit warning for high contrast pair', () => { + const state = buildState({ + colors: { + 'black': '#000000', + 'white': '#ffffff', + }, + components: { + 'button-good': { + backgroundColor: '{colors.black}', + textColor: '{colors.white}', + }, + }, + }); + const result = linter.lint(state); + const contrastWarnings = result.diagnostics.filter( + (d: Diagnostic) => d.severity === 'warning' && d.message.includes('contrast') + ); + expect(contrastWarnings.length).toBe(0); + }); + }); + + // ── W4: Non-standard dimension unit emits warning ──────────────── + describe('W4: non-standard dimension unit', () => { + it('emits warning when typography uses em unit', () => { + const state = buildState({ + colors: { primary: '#000000' }, + typography: { + 'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02em' }, + }, + }); + const result = linter.lint(state); + const warnings = result.diagnostics.filter( + (d: Diagnostic) => d.severity === 'warning' && d.message.includes('non-standard') + ); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]!.message).toMatch(/em/); + expect(warnings[0]!.message).toMatch(/px|rem/); + }); + + it('does NOT emit warning for standard px/rem units', () => { + const state = buildState({ + colors: { primary: '#000000' }, + typography: { + 'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '1.2px' }, + }, + }); + const result = linter.lint(state); + const nonStdWarnings = result.diagnostics.filter( + (d: Diagnostic) => d.severity === 'warning' && d.message.includes('non-standard') + ); + expect(nonStdWarnings.length).toBe(0); + }); + }); + + // ── Cycle 19: I1 — Token count summary emits info ──────────────── + describe('I1: token count summary', () => { + it('emits an info diagnostic summarizing the token counts', () => { + const state = buildState({ + colors: { primary: '#ff0000', secondary: '#00ff00' }, + typography: { + 'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500 }, + }, + rounded: { regular: '4px' }, + spacing: { 'gutter-s': '8px' }, + }); + const result = linter.lint(state); + const infos = result.diagnostics.filter((d: Diagnostic) => d.severity === 'info'); + expect(infos.some((d: Diagnostic) => d.message.includes('2 color') && d.message.includes('1 typography'))).toBe(true); + }); + }); + + // ── Cycle 20: Clean document produces zero errors ───────────────── + describe('clean document', () => { + it('produces zero errors for a valid design system', () => { + const state = buildState({ + colors: { primary: '#647D66', secondary: '#ff0000' }, + typography: { + 'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500, lineHeight: '50px', letterSpacing: '1.2px' }, + }, + rounded: { regular: '4px', lg: '8px' }, + spacing: { 'gutter-s': '8px', 'gutter-l': '16px' }, + components: { + 'button-primary': { + backgroundColor: '{colors.primary}', + textColor: '#ffffff', + }, + }, + }); + const result = linter.lint(state); + const errors = result.diagnostics.filter((d: Diagnostic) => d.severity === 'error'); + expect(errors.length).toBe(0); + }); + }); + + // ── Cycle 21: preEvaluate graded menu ───────────────────────────── + describe('preEvaluate graded menu', () => { + it('groups diagnostics into fixes, improvements, and suggestions', () => { + const state = buildState({ + colors: { + accent: 'red', // error: invalid color + secondary: '#ffff00', + white: '#ffffff', + }, + components: { + 'button-bad': { + backgroundColor: '{colors.secondary}', + textColor: '{colors.white}', // warning: low contrast + }, + }, + }); + const graded = linter.preEvaluate(state); + expect(graded.fixes.length).toBeGreaterThan(0); + expect(graded.improvements.length).toBeGreaterThan(0); + expect(graded.suggestions.length).toBeGreaterThan(0); // at least the summary info + }); + }); + + // ── Summary counts ─────────────────────────────────────────────── + describe('summary counts', () => { + it('correctly counts errors, warnings, and infos', () => { + const state = buildState({ + colors: { accent: 'red' }, + }); + const result = linter.lint(state); + expect(result.summary.errors).toBe(result.diagnostics.filter((d: Diagnostic) => d.severity === 'error').length); + expect(result.summary.warnings).toBe(result.diagnostics.filter((d: Diagnostic) => d.severity === 'warning').length); + expect(result.summary.infos).toBe(result.diagnostics.filter((d: Diagnostic) => d.severity === 'info').length); + }); + }); +}); diff --git a/packages/linter/src/linter/handler.ts b/packages/linter/src/linter/handler.ts new file mode 100644 index 00000000..aa5421a7 --- /dev/null +++ b/packages/linter/src/linter/handler.ts @@ -0,0 +1,21 @@ +import type { + LinterSpec, + LintResult, + GradedTokenEdits, +} from './spec.js'; +import type { DesignSystemState } from '../model/spec.js'; +import { runLinter, preEvaluate } from './runner.js'; + +/** + * @deprecated Use `runLinter()` and `preEvaluate()` from './runner.js' directly. + * This class exists only for backward compatibility. + */ +export class LinterHandler implements LinterSpec { + lint(state: DesignSystemState): LintResult { + return runLinter(state); + } + + preEvaluate(state: DesignSystemState): GradedTokenEdits { + return preEvaluate(state); + } +} diff --git a/packages/linter/src/linter/rules/broken-ref.test.ts b/packages/linter/src/linter/rules/broken-ref.test.ts new file mode 100644 index 00000000..c2af07a7 --- /dev/null +++ b/packages/linter/src/linter/rules/broken-ref.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'bun:test'; +import { brokenRef } from './broken-ref.js'; +import { buildState } from './test-helpers.js'; + +describe('brokenRef', () => { + it('emits error for unresolved token reference', () => { + const state = buildState({ + colors: { primary: '#ff0000' }, + components: { button: { backgroundColor: '{colors.nonexistent}' } }, + }); + const diagnostics = brokenRef(state); + expect(diagnostics.some(d => d.message.includes('does not resolve'))).toBe(true); + }); + + it('returns empty when all references resolve', () => { + const state = buildState({ + colors: { primary: '#ff0000' }, + components: { button: { backgroundColor: '{colors.primary}' } }, + }); + const errors = brokenRef(state).filter(d => d.message.includes('does not resolve')); + expect(errors.length).toBe(0); + }); +}); diff --git a/packages/linter/src/linter/rules/broken-ref.ts b/packages/linter/src/linter/rules/broken-ref.ts new file mode 100644 index 00000000..e89a7a96 --- /dev/null +++ b/packages/linter/src/linter/rules/broken-ref.ts @@ -0,0 +1,32 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; +import { VALID_COMPONENT_SUB_TOKENS } from '../../model/spec.js'; + +/** + * Broken/circular references and unknown component sub-tokens. + */ +export function brokenRef(state: DesignSystemState): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const [compName, comp] of state.components) { + // Unresolved references + for (const ref of comp.unresolvedRefs) { + diagnostics.push({ + severity: 'error', + path: `components.${compName}`, + message: `Reference ${ref} does not resolve to any defined token.`, + }); + } + + // Unknown component sub-tokens + for (const [propName] of comp.properties) { + if (!(VALID_COMPONENT_SUB_TOKENS as readonly string[]).includes(propName)) { + diagnostics.push({ + severity: 'error', + path: `components.${compName}.${propName}`, + message: `'${propName}' is not a recognized component sub-token. Valid sub-tokens: ${VALID_COMPONENT_SUB_TOKENS.join(', ')}.`, + }); + } + } + } + return diagnostics; +} diff --git a/packages/linter/src/linter/rules/contrast-ratio.test.ts b/packages/linter/src/linter/rules/contrast-ratio.test.ts new file mode 100644 index 00000000..9b22d842 --- /dev/null +++ b/packages/linter/src/linter/rules/contrast-ratio.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'bun:test'; +import { contrastCheck } from './contrast-ratio.js'; +import { buildState } from './test-helpers.js'; + +describe('contrastCheck', () => { + it('emits warning for low contrast pair', () => { + const state = buildState({ + colors: { yellow: '#ffff00', white: '#ffffff' }, + components: { + 'button-bad': { backgroundColor: '{colors.yellow}', textColor: '{colors.white}' }, + }, + }); + const diagnostics = contrastCheck(state); + expect(diagnostics.length).toBe(1); + expect(diagnostics[0]!.severity).toBe('warning'); + expect(diagnostics[0]!.message).toMatch(/contrast/); + }); + + it('returns empty for high contrast pair', () => { + const state = buildState({ + colors: { black: '#000000', white: '#ffffff' }, + components: { + 'button-good': { backgroundColor: '{colors.black}', textColor: '{colors.white}' }, + }, + }); + const contrastWarnings = contrastCheck(state).filter(d => d.message.includes('contrast')); + expect(contrastWarnings.length).toBe(0); + }); +}); diff --git a/packages/linter/src/linter/rules/contrast-ratio.ts b/packages/linter/src/linter/rules/contrast-ratio.ts new file mode 100644 index 00000000..94fe2522 --- /dev/null +++ b/packages/linter/src/linter/rules/contrast-ratio.ts @@ -0,0 +1,39 @@ +import type { DesignSystemState, ResolvedColor, ResolvedValue } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; +import { contrastRatio } from '../../model/handler.js'; + +const WCAG_AA_MINIMUM = 4.5; + +/** + * WCAG contrast ratio — warns when component backgroundColor/textColor pairs + * fall below the AA minimum of 4.5:1. + */ +export function contrastCheck(state: DesignSystemState): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const [compName, comp] of state.components) { + const bgValue = comp.properties.get('backgroundColor'); + const textValue = comp.properties.get('textColor'); + if (!bgValue || !textValue) continue; + + const bgColor = resolveToColor(bgValue); + const textColor = resolveToColor(textValue); + if (!bgColor || !textColor) continue; + + const ratio = contrastRatio(bgColor, textColor); + if (ratio < WCAG_AA_MINIMUM) { + diagnostics.push({ + severity: 'warning', + path: `components.${compName}`, + message: `textColor (${textColor.hex}) on backgroundColor (${bgColor.hex}) has contrast ratio ${ratio.toFixed(2)}:1, below WCAG AA minimum of ${WCAG_AA_MINIMUM}:1.`, + }); + } + } + return diagnostics; +} + +function resolveToColor(value: ResolvedValue): ResolvedColor | null { + if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') { + return value as ResolvedColor; + } + return null; +} diff --git a/packages/linter/src/linter/rules/index.ts b/packages/linter/src/linter/rules/index.ts new file mode 100644 index 00000000..3e146336 --- /dev/null +++ b/packages/linter/src/linter/rules/index.ts @@ -0,0 +1,32 @@ +import type { LintRule } from './types.js'; +import { invalidColor } from './invalid-color.js'; +import { brokenRef } from './broken-ref.js'; +import { missingPrimary } from './missing-primary.js'; +import { contrastCheck } from './contrast-ratio.js'; +import { orphanedTokens } from './orphaned-tokens.js'; +import { nonStandardUnit } from './non-standard-unit.js'; +import { tokenSummary } from './token-summary.js'; +import { missingSections } from './missing-sections.js'; + +/** The default set of lint rules, executed in order. */ +export const DEFAULT_RULES: LintRule[] = [ + invalidColor, + brokenRef, + missingPrimary, + contrastCheck, + orphanedTokens, + nonStandardUnit, + tokenSummary, + missingSections, +]; + +// Re-export individual rules for selective composition +export { invalidColor } from './invalid-color.js'; +export { brokenRef } from './broken-ref.js'; +export { missingPrimary } from './missing-primary.js'; +export { contrastCheck } from './contrast-ratio.js'; +export { orphanedTokens } from './orphaned-tokens.js'; +export { nonStandardUnit } from './non-standard-unit.js'; +export { tokenSummary } from './token-summary.js'; +export { missingSections } from './missing-sections.js'; +export type { LintRule } from './types.js'; diff --git a/packages/linter/src/linter/rules/invalid-color.test.ts b/packages/linter/src/linter/rules/invalid-color.test.ts new file mode 100644 index 00000000..168298bd --- /dev/null +++ b/packages/linter/src/linter/rules/invalid-color.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'bun:test'; +import { invalidColor } from './invalid-color.js'; +import { buildState } from './test-helpers.js'; + +describe('invalidColor', () => { + it('emits error for non-hex color values', () => { + const state = buildState({ colors: { accent: 'red' } }); + const diagnostics = invalidColor(state); + expect(diagnostics.length).toBe(1); + expect(diagnostics[0]!.severity).toBe('error'); + expect(diagnostics[0]!.message).toMatch(/red/); + expect(diagnostics[0]!.message).toMatch(/hex/); + }); + + it('returns empty for valid hex colors', () => { + const state = buildState({ colors: { primary: '#647D66' } }); + expect(invalidColor(state)).toEqual([]); + }); + + it('skips token references (handled by broken-ref rule)', () => { + const state = buildState({ colors: { alias: '{colors.primary}' as string } }); + expect(invalidColor(state)).toEqual([]); + }); +}); diff --git a/packages/linter/src/linter/rules/invalid-color.ts b/packages/linter/src/linter/rules/invalid-color.ts new file mode 100644 index 00000000..01aecd83 --- /dev/null +++ b/packages/linter/src/linter/rules/invalid-color.ts @@ -0,0 +1,23 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; +import { isValidColor, isTokenReference } from '../../model/spec.js'; + +/** + * Invalid color format — colors must be valid hex (#RGB or #RRGGBB). + */ +export function invalidColor(state: DesignSystemState): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const [key, value] of state.symbolTable) { + if (key.startsWith('colors.') && typeof value === 'string') { + if (isTokenReference(value)) continue; + if (!isValidColor(value)) { + diagnostics.push({ + severity: 'error', + path: key, + message: `'${value}' is not a valid hex color. Expected #RGB or #RRGGBB.`, + }); + } + } + } + return diagnostics; +} diff --git a/packages/linter/src/linter/rules/missing-primary.test.ts b/packages/linter/src/linter/rules/missing-primary.test.ts new file mode 100644 index 00000000..73bf8f7f --- /dev/null +++ b/packages/linter/src/linter/rules/missing-primary.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'bun:test'; +import { missingPrimary } from './missing-primary.js'; +import { buildState } from './test-helpers.js'; + +describe('missingPrimary', () => { + it('emits warning when colors exist but no primary', () => { + const state = buildState({ colors: { accent: '#ff0000' } }); + const diagnostics = missingPrimary(state); + expect(diagnostics.length).toBe(1); + expect(diagnostics[0]!.severity).toBe('warning'); + expect(diagnostics[0]!.message).toMatch(/primary/); + }); + + it('returns empty when primary IS defined', () => { + const state = buildState({ colors: { primary: '#ff0000' } }); + expect(missingPrimary(state)).toEqual([]); + }); + + it('returns empty when no colors defined', () => { + const state = buildState({}); + expect(missingPrimary(state)).toEqual([]); + }); +}); diff --git a/packages/linter/src/linter/rules/missing-primary.ts b/packages/linter/src/linter/rules/missing-primary.ts new file mode 100644 index 00000000..145270f5 --- /dev/null +++ b/packages/linter/src/linter/rules/missing-primary.ts @@ -0,0 +1,16 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; + +/** + * Missing primary color — warns when colors are defined but no 'primary' exists. + */ +export function missingPrimary(state: DesignSystemState): Diagnostic[] { + if (state.colors.size > 0 && !state.colors.has('primary')) { + return [{ + severity: 'warning', + path: 'colors', + message: "No 'primary' color defined. The agent will auto-generate key colors, reducing your control over the palette.", + }]; + } + return []; +} diff --git a/packages/linter/src/linter/rules/missing-sections.test.ts b/packages/linter/src/linter/rules/missing-sections.test.ts new file mode 100644 index 00000000..79b38817 --- /dev/null +++ b/packages/linter/src/linter/rules/missing-sections.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'bun:test'; +import { missingSections } from './missing-sections.js'; +import { buildState } from './test-helpers.js'; + +describe('missingSections', () => { + it('emits info when spacing is missing but colors exist', () => { + const state = buildState({ + colors: { primary: '#ff0000' }, + rounded: { regular: '4px' }, + // no spacing + }); + const diagnostics = missingSections(state); + const spacingNote = diagnostics.find(d => d.path === 'spacing'); + expect(spacingNote).toBeDefined(); + expect(spacingNote!.severity).toBe('info'); + expect(spacingNote!.message).toMatch(/spacing/); + }); + + it('returns empty when all sections present', () => { + const state = buildState({ + colors: { primary: '#ff0000' }, + rounded: { regular: '4px' }, + spacing: { unit: '8px' }, + }); + expect(missingSections(state)).toEqual([]); + }); + + it('returns empty when no colors exist (nothing to compare against)', () => { + const state = buildState({}); + expect(missingSections(state)).toEqual([]); + }); +}); diff --git a/packages/linter/src/linter/rules/missing-sections.ts b/packages/linter/src/linter/rules/missing-sections.ts new file mode 100644 index 00000000..65b809b4 --- /dev/null +++ b/packages/linter/src/linter/rules/missing-sections.ts @@ -0,0 +1,24 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; + +/** + * Missing sections — notes when optional sections (spacing, rounded) are absent. + */ +export function missingSections(state: DesignSystemState): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const sections = [ + { map: state.spacing, name: 'spacing', fallback: 'Layout spacing will fall back to agent defaults.' }, + { map: state.rounded, name: 'rounded', fallback: 'Corner rounding will fall back to agent defaults.' }, + ]; + + for (const { map, name, fallback } of sections) { + if (map.size === 0 && state.colors.size > 0) { + diagnostics.push({ + severity: 'info', + path: name, + message: `No '${name}' section defined. ${fallback}`, + }); + } + } + return diagnostics; +} diff --git a/packages/linter/src/linter/rules/non-standard-unit.test.ts b/packages/linter/src/linter/rules/non-standard-unit.test.ts new file mode 100644 index 00000000..4422610d --- /dev/null +++ b/packages/linter/src/linter/rules/non-standard-unit.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'bun:test'; +import { nonStandardUnit } from './non-standard-unit.js'; +import { buildState } from './test-helpers.js'; + +describe('nonStandardUnit', () => { + it('emits warning when typography uses em unit', () => { + const state = buildState({ + colors: { primary: '#000000' }, + typography: { + headline: { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02em' }, + }, + }); + const diagnostics = nonStandardUnit(state); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0]!.message).toMatch(/non-standard/); + expect(diagnostics[0]!.message).toMatch(/em/); + }); + + it('returns empty for standard px/rem units', () => { + const state = buildState({ + colors: { primary: '#000000' }, + typography: { + headline: { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '1.2px' }, + }, + }); + const nonStd = nonStandardUnit(state).filter(d => d.message.includes('non-standard')); + expect(nonStd.length).toBe(0); + }); +}); diff --git a/packages/linter/src/linter/rules/non-standard-unit.ts b/packages/linter/src/linter/rules/non-standard-unit.ts new file mode 100644 index 00000000..5334bd47 --- /dev/null +++ b/packages/linter/src/linter/rules/non-standard-unit.ts @@ -0,0 +1,50 @@ +import type { DesignSystemState, ResolvedDimension } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; +import { isStandardDimension } from '../../model/spec.js'; + +const BASE_FONT_SIZE = 16; + +/** + * Non-standard dimension unit — warns when dimensions use units + * other than px or rem (the spec-standard units). + */ +export function nonStandardUnit(state: DesignSystemState): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + const checkDimension = (dim: ResolvedDimension, path: string) => { + const raw = `${dim.value}${dim.unit}`; + if (!isStandardDimension(raw)) { + let suggestion = `consider converting to px or rem`; + if (dim.unit === 'em' || dim.unit === 'rem') { + const pxValue = dim.value * BASE_FONT_SIZE; + suggestion = `consider converting: ${raw} ≈ ${pxValue}px (at ${BASE_FONT_SIZE}px base)`; + } + diagnostics.push({ + severity: 'warning', + path, + message: `'${raw}' uses non-standard unit '${dim.unit}'. Spec recommends px or rem — ${suggestion}.`, + }); + } + }; + + // Check typography dimension properties + for (const [name, typo] of state.typography) { + const dimProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const; + for (const prop of dimProps) { + const dim = typo[prop]; + if (dim) checkDimension(dim, `typography.${name}.${prop}`); + } + } + + // Check rounded + for (const [name, dim] of state.rounded) { + checkDimension(dim, `rounded.${name}`); + } + + // Check spacing + for (const [name, dim] of state.spacing) { + checkDimension(dim, `spacing.${name}`); + } + + return diagnostics; +} diff --git a/packages/linter/src/linter/rules/orphaned-tokens.test.ts b/packages/linter/src/linter/rules/orphaned-tokens.test.ts new file mode 100644 index 00000000..f49624e7 --- /dev/null +++ b/packages/linter/src/linter/rules/orphaned-tokens.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'bun:test'; +import { orphanedTokens } from './orphaned-tokens.js'; +import { buildState } from './test-helpers.js'; + +describe('orphanedTokens', () => { + it('emits warning for color not referenced by any component', () => { + const state = buildState({ + colors: { primary: '#ff0000', unused: '#00ff00' }, + components: { + button: { backgroundColor: '{colors.primary}' }, + }, + }); + const diagnostics = orphanedTokens(state); + const orphan = diagnostics.find(d => d.message.includes('unused')); + expect(orphan).toBeDefined(); + expect(orphan!.severity).toBe('warning'); + }); + + it('returns empty when no components exist', () => { + const state = buildState({ colors: { primary: '#ff0000' } }); + expect(orphanedTokens(state)).toEqual([]); + }); +}); diff --git a/packages/linter/src/linter/rules/orphaned-tokens.ts b/packages/linter/src/linter/rules/orphaned-tokens.ts new file mode 100644 index 00000000..92f9b391 --- /dev/null +++ b/packages/linter/src/linter/rules/orphaned-tokens.ts @@ -0,0 +1,35 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; + +/** + * Orphaned tokens — tokens defined but never referenced by any component. + */ +export function orphanedTokens(state: DesignSystemState): Diagnostic[] { + if (state.components.size === 0) return []; + + const referencedPaths = new Set(); + for (const [, comp] of state.components) { + for (const [, value] of comp.properties) { + if (typeof value === 'object' && value !== null && 'type' in value) { + for (const [key, symValue] of state.symbolTable) { + if (symValue === value) { + referencedPaths.add(key); + } + } + } + } + } + + const diagnostics: Diagnostic[] = []; + for (const [name] of state.colors) { + const path = `colors.${name}`; + if (!referencedPaths.has(path)) { + diagnostics.push({ + severity: 'warning', + path, + message: `'${name}' is defined but never referenced by any component.`, + }); + } + } + return diagnostics; +} diff --git a/packages/linter/src/linter/rules/test-helpers.ts b/packages/linter/src/linter/rules/test-helpers.ts new file mode 100644 index 00000000..37abe1bd --- /dev/null +++ b/packages/linter/src/linter/rules/test-helpers.ts @@ -0,0 +1,16 @@ +/** + * Shared test helper for rule unit tests. + * Builds a DesignSystemState from parsed overrides, reusing the ModelHandler. + */ +import { ModelHandler } from '../../model/handler.js'; +import type { ParsedDesignSystem } from '../../parser/spec.js'; +import type { DesignSystemState } from '../../model/spec.js'; + +const modelHandler = new ModelHandler(); + +export function buildState(overrides: Partial = {}): DesignSystemState { + const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; + const result = modelHandler.execute(parsed); + if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); + return result.data; +} diff --git a/packages/linter/src/linter/rules/token-summary.test.ts b/packages/linter/src/linter/rules/token-summary.test.ts new file mode 100644 index 00000000..4d855652 --- /dev/null +++ b/packages/linter/src/linter/rules/token-summary.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'bun:test'; +import { tokenSummary } from './token-summary.js'; +import { buildState } from './test-helpers.js'; + +describe('tokenSummary', () => { + it('emits info diagnostic with token counts', () => { + const state = buildState({ + colors: { primary: '#ff0000', secondary: '#00ff00' }, + typography: { 'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500 } }, + rounded: { regular: '4px' }, + spacing: { 'gutter-s': '8px' }, + }); + const diagnostics = tokenSummary(state); + expect(diagnostics.length).toBe(1); + expect(diagnostics[0]!.severity).toBe('info'); + expect(diagnostics[0]!.message).toMatch(/2 colors/); + expect(diagnostics[0]!.message).toMatch(/1 typography/); + }); + + it('returns empty for completely empty state', () => { + const state = buildState({}); + expect(tokenSummary(state)).toEqual([]); + }); +}); diff --git a/packages/linter/src/linter/rules/token-summary.ts b/packages/linter/src/linter/rules/token-summary.ts new file mode 100644 index 00000000..525ff08d --- /dev/null +++ b/packages/linter/src/linter/rules/token-summary.ts @@ -0,0 +1,23 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; + +/** + * Token count summary — emits an info diagnostic summarizing how many + * tokens are defined in each section. + */ +export function tokenSummary(state: DesignSystemState): Diagnostic[] { + const parts: string[] = []; + if (state.colors.size > 0) parts.push(`${state.colors.size} color${state.colors.size !== 1 ? 's' : ''}`); + if (state.typography.size > 0) parts.push(`${state.typography.size} typography scale${state.typography.size !== 1 ? 's' : ''}`); + if (state.rounded.size > 0) parts.push(`${state.rounded.size} rounding level${state.rounded.size !== 1 ? 's' : ''}`); + if (state.spacing.size > 0) parts.push(`${state.spacing.size} spacing token${state.spacing.size !== 1 ? 's' : ''}`); + if (state.components.size > 0) parts.push(`${state.components.size} component${state.components.size !== 1 ? 's' : ''}`); + + if (parts.length > 0) { + return [{ + severity: 'info', + message: `Design system defines ${parts.join(', ')}.`, + }]; + } + return []; +} diff --git a/packages/linter/src/linter/rules/types.test.ts b/packages/linter/src/linter/rules/types.test.ts new file mode 100644 index 00000000..466b50a0 --- /dev/null +++ b/packages/linter/src/linter/rules/types.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'bun:test'; +import type { LintRule } from './types.js'; + +describe('LintRule type', () => { + it('accepts a function that takes state and returns diagnostics', () => { + const rule: LintRule = (_state) => []; + expect(rule({ + colors: new Map(), + typography: new Map(), + rounded: new Map(), + spacing: new Map(), + components: new Map(), + symbolTable: new Map(), + })).toEqual([]); + }); +}); diff --git a/packages/linter/src/linter/rules/types.ts b/packages/linter/src/linter/rules/types.ts new file mode 100644 index 00000000..bb71be54 --- /dev/null +++ b/packages/linter/src/linter/rules/types.ts @@ -0,0 +1,5 @@ +import type { DesignSystemState } from '../../model/spec.js'; +import type { Diagnostic } from '../spec.js'; + +/** A pure lint rule: takes immutable state, returns diagnostics. No side effects. */ +export type LintRule = (state: DesignSystemState) => Diagnostic[]; diff --git a/packages/linter/src/linter/runner.test.ts b/packages/linter/src/linter/runner.test.ts new file mode 100644 index 00000000..76fa9af9 --- /dev/null +++ b/packages/linter/src/linter/runner.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'bun:test'; +import { runLinter, preEvaluate } from './runner.js'; +import type { LintRule } from './rules/types.js'; +import { missingPrimary } from './rules/missing-primary.js'; +import { tokenSummary } from './rules/token-summary.js'; +import { ModelHandler } from '../model/handler.js'; +import type { ParsedDesignSystem } from '../parser/spec.js'; +import type { DesignSystemState } from '../model/spec.js'; + +const modelHandler = new ModelHandler(); + +function buildState(overrides: Partial = {}): DesignSystemState { + const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; + const result = modelHandler.execute(parsed); + if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); + return result.data; +} + +describe('runLinter', () => { + it('runs default rules when none specified', () => { + const state = buildState({ colors: { accent: '#ff0000' } }); + const result = runLinter(state); + // Should have at least a warning (missing primary) and an info (summary) + expect(result.summary.warnings).toBeGreaterThan(0); + expect(result.summary.infos).toBeGreaterThan(0); + }); + + it('runs only the specified subset of rules', () => { + const state = buildState({ colors: { accent: '#ff0000' } }); + const customRules: LintRule[] = [missingPrimary]; + const result = runLinter(state, customRules); + // Only the missing primary warning — no summary info + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.message).toMatch(/primary/); + expect(result.summary.warnings).toBe(1); + expect(result.summary.infos).toBe(0); + }); + + it('returns empty diagnostics for empty rules array', () => { + const state = buildState({ colors: { accent: '#ff0000' } }); + const result = runLinter(state, []); + expect(result.diagnostics).toEqual([]); + expect(result.summary).toEqual({ errors: 0, warnings: 0, infos: 0 }); + }); +}); + +describe('preEvaluate', () => { + it('groups diagnostics into fixes, improvements, and suggestions', () => { + const state = buildState({ + colors: { accent: 'red', secondary: '#ffff00', white: '#ffffff' }, + components: { + 'button-bad': { + backgroundColor: '{colors.secondary}', + textColor: '{colors.white}', + }, + }, + }); + const graded = preEvaluate(state); + expect(graded.fixes.length).toBeGreaterThan(0); // error: invalid color + expect(graded.improvements.length).toBeGreaterThan(0); // warning: contrast + expect(graded.suggestions.length).toBeGreaterThan(0); // info: summary + }); + + it('accepts custom rules', () => { + const state = buildState({ colors: { accent: '#ff0000' } }); + const graded = preEvaluate(state, [tokenSummary]); + expect(graded.fixes).toEqual([]); + expect(graded.improvements).toEqual([]); + expect(graded.suggestions.length).toBe(1); + }); +}); diff --git a/packages/linter/src/linter/runner.ts b/packages/linter/src/linter/runner.ts new file mode 100644 index 00000000..995c363a --- /dev/null +++ b/packages/linter/src/linter/runner.ts @@ -0,0 +1,45 @@ +import type { DesignSystemState } from '../model/spec.js'; +import type { LintResult, Diagnostic, GradedTokenEdits, TokenEditEntry } from './spec.js'; +import type { LintRule } from './rules/types.js'; +import { DEFAULT_RULES } from './rules/index.js'; + +/** + * Pure functional linter runner. + * Executes each rule against the state and aggregates diagnostics. + */ +export function runLinter( + state: DesignSystemState, + rules: LintRule[] = DEFAULT_RULES, +): LintResult { + const diagnostics: Diagnostic[] = rules.flatMap(rule => rule(state)); + return { + diagnostics, + summary: { + errors: diagnostics.filter(d => d.severity === 'error').length, + warnings: diagnostics.filter(d => d.severity === 'warning').length, + infos: diagnostics.filter(d => d.severity === 'info').length, + }, + }; +} + +/** + * Groups lint diagnostics into a graded edit menu (fixes / improvements / suggestions). + */ +export function preEvaluate( + state: DesignSystemState, + rules: LintRule[] = DEFAULT_RULES, +): GradedTokenEdits { + const { diagnostics } = runLinter(state, rules); + const fixes: TokenEditEntry[] = []; + const improvements: TokenEditEntry[] = []; + const suggestions: TokenEditEntry[] = []; + + for (const d of diagnostics) { + const entry: TokenEditEntry = { path: d.path ?? '', diagnostics: [d] }; + if (d.severity === 'error') fixes.push(entry); + else if (d.severity === 'warning') improvements.push(entry); + else suggestions.push(entry); + } + + return { fixes, improvements, suggestions }; +} diff --git a/packages/linter/src/linter/spec.ts b/packages/linter/src/linter/spec.ts new file mode 100644 index 00000000..a6a6540b --- /dev/null +++ b/packages/linter/src/linter/spec.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; +import type { DesignSystemState, ResolvedValue } from '../model/spec.js'; + +// ── SEVERITY ─────────────────────────────────────────────────────── +export const SeveritySchema = z.enum(['error', 'warning', 'info']); +export type Severity = z.infer; + +// ── DIAGNOSTIC ───────────────────────────────────────────────────── +export interface Diagnostic { + severity: Severity; + /** Token path, e.g. "colors.primary", "components.button-primary.textColor" */ + path?: string; + message: string; +} + +// ── LINT RESULT ──────────────────────────────────────────────────── +export interface LintResult { + diagnostics: Diagnostic[]; + summary: { + errors: number; + warnings: number; + infos: number; + }; +} + +// ── GRADED TOKEN EDITS ───────────────────────────────────────────── +export interface GradedTokenEdits { + /** Edits that fix errors (highest priority) */ + fixes: TokenEditEntry[]; + /** Edits that resolve warnings */ + improvements: TokenEditEntry[]; + /** Edits that are purely additive / informational */ + suggestions: TokenEditEntry[]; +} + +export interface TokenEditEntry { + path: string; + currentValue?: string; + suggestedValue?: string; + diagnostics: Diagnostic[]; +} + +// ── INTERFACE ────────────────────────────────────────────────────── +export interface LinterSpec { + lint(state: DesignSystemState): LintResult; + preEvaluate(state: DesignSystemState): GradedTokenEdits; +} diff --git a/packages/linter/src/model/handler.test.ts b/packages/linter/src/model/handler.test.ts new file mode 100644 index 00000000..6b41eb35 --- /dev/null +++ b/packages/linter/src/model/handler.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'bun:test'; +import { ModelHandler, contrastRatio } from './handler.js'; +import type { ParsedDesignSystem } from '../parser/spec.js'; + +const handler = new ModelHandler(); + +function makeParsed(overrides: Partial = {}): ParsedDesignSystem { + return { + sourceMap: new Map(), + ...overrides, + }; +} + +describe('ModelHandler', () => { + // ── Cycle 9: Build symbol table from parsed colors ──────────────── + describe('symbol table from colors', () => { + it('resolves valid hex colors into the symbol table', () => { + const result = handler.execute(makeParsed({ + colors: { primary: '#647D66', secondary: '#ff0000' }, + })); + expect(result.success).toBe(true); + if (result.success) { + const primary = result.data.symbolTable.get('colors.primary'); + expect(primary).toBeDefined(); + expect(typeof primary === 'object' && primary !== null && 'type' in primary && primary.type === 'color').toBe(true); + if (typeof primary === 'object' && primary !== null && 'hex' in primary) { + expect(primary.hex).toBe('#647d66'); + } + + expect(result.data.colors.size).toBe(2); + } + }); + + it('normalizes #RGB shorthand to #RRGGBB', () => { + const result = handler.execute(makeParsed({ + colors: { accent: '#abc' }, + })); + expect(result.success).toBe(true); + if (result.success) { + const accent = result.data.colors.get('accent'); + expect(accent?.hex).toBe('#aabbcc'); + } + }); + }); + + // ── Cycle 10: Resolve single-level token reference ──────────────── + describe('single-level token reference resolution', () => { + it('resolves a direct {section.token} reference in components', () => { + const result = handler.execute(makeParsed({ + colors: { primary: '#647D66' }, + components: { + 'button-primary': { + backgroundColor: '{colors.primary}', + }, + }, + })); + expect(result.success).toBe(true); + if (result.success) { + const btn = result.data.components.get('button-primary'); + expect(btn).toBeDefined(); + const bg = btn?.properties.get('backgroundColor'); + expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); + } + }); + }); + + // ── Cycle 11: Resolve chained token reference ───────────────────── + describe('chained token reference resolution', () => { + it('resolves chained refs: {a} → {b} → #value', () => { + const result = handler.execute(makeParsed({ + colors: { + 'brand': '#647D66', + 'primary': '{colors.brand}' as string, + }, + components: { + 'button': { + backgroundColor: '{colors.primary}', + }, + }, + })); + expect(result.success).toBe(true); + if (result.success) { + const btn = result.data.components.get('button'); + const bg = btn?.properties.get('backgroundColor'); + expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); + if (typeof bg === 'object' && bg !== null && 'hex' in bg) { + expect(bg.hex).toBe('#647d66'); + } + } + }); + }); + + // ── Cycle 12: Detect circular reference ─────────────────────────── + describe('circular reference detection', () => { + it('detects circular refs and records them as unresolved', () => { + const result = handler.execute(makeParsed({ + colors: { + 'a': '{colors.b}' as string, + 'b': '{colors.a}' as string, + }, + components: { + 'card': { + backgroundColor: '{colors.a}', + }, + }, + })); + expect(result.success).toBe(true); + if (result.success) { + const card = result.data.components.get('card'); + expect(card?.unresolvedRefs.length).toBeGreaterThan(0); + } + }); + }); + + // ── Cycle N: Non-standard units are parsed, not dropped ──────────── + describe('non-standard dimension units', () => { + it('preserves em units in typography letterSpacing', () => { + const result = handler.execute(makeParsed({ + typography: { + 'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02em' }, + }, + })); + expect(result.success).toBe(true); + if (result.success) { + const headline = result.data.typography.get('headline'); + expect(headline?.letterSpacing).toBeDefined(); + expect(headline?.letterSpacing?.value).toBe(-0.02); + expect(headline?.letterSpacing?.unit).toBe('em'); + } + }); + }); + + // ── Cycle 13: Compute WCAG contrast ratio ───────────────────────── + describe('WCAG contrast ratio', () => { + it('computes correct contrast ratio for black on white (21:1)', () => { + const result = handler.execute(makeParsed({ + colors: { black: '#000000', white: '#ffffff' }, + })); + expect(result.success).toBe(true); + if (result.success) { + const black = result.data.colors.get('black'); + const white = result.data.colors.get('white'); + expect(black).toBeDefined(); + expect(white).toBeDefined(); + + const ratio = contrastRatio(black!, white!); + expect(ratio).toBeCloseTo(21, 0); + } + }); + + it('computes correct contrast for identical colors (1:1)', () => { + const result = handler.execute(makeParsed({ + colors: { red1: '#ff0000', red2: '#ff0000' }, + })); + expect(result.success).toBe(true); + if (result.success) { + const ratio = contrastRatio(result.data.colors.get('red1')!, result.data.colors.get('red2')!); + expect(ratio).toBeCloseTo(1, 1); + } + }); + }); +}); diff --git a/packages/linter/src/model/handler.ts b/packages/linter/src/model/handler.ts new file mode 100644 index 00000000..21224e99 --- /dev/null +++ b/packages/linter/src/model/handler.ts @@ -0,0 +1,260 @@ +import type { ParsedDesignSystem } from '../parser/spec.js'; +import type { + ModelSpec, + ModelResult, + DesignSystemState, + ResolvedColor, + ResolvedDimension, + ResolvedTypography, + ResolvedValue, + ComponentDef, +} from './spec.js'; +import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts } from './spec.js'; + +const MAX_REFERENCE_DEPTH = 10; + +/** + * Builds a resolved DesignSystemState from parsed YAML tokens. + * Handles color parsing, dimension parsing, typography construction, + * and chained token reference resolution with cycle detection. + * Never throws — all errors returned as ModelResult failures. + */ +export class ModelHandler implements ModelSpec { + execute(input: ParsedDesignSystem): ModelResult { + try { + const symbolTable = new Map(); + const colors = new Map(); + const typography = new Map(); + const rounded = new Map(); + const spacing = new Map(); + + // ── Phase 1: Resolve primitive tokens ────────────────────────── + // Colors + if (input.colors) { + for (const [name, raw] of Object.entries(input.colors)) { + if (isTokenReference(raw)) { + // Store raw reference for later resolution + symbolTable.set(`colors.${name}`, raw); + } else if (isValidColor(raw)) { + const resolved = parseColor(raw); + colors.set(name, resolved); + symbolTable.set(`colors.${name}`, resolved); + } else { + // Store as-is for linter to catch + symbolTable.set(`colors.${name}`, raw); + } + } + } + + // Typography + if (input.typography) { + for (const [name, props] of Object.entries(input.typography)) { + const resolved = parseTypography(props); + typography.set(name, resolved); + symbolTable.set(`typography.${name}`, resolved); + } + } + + // Rounded + if (input.rounded) { + for (const [name, raw] of Object.entries(input.rounded)) { + if (isParseableDimension(raw)) { + const resolved = parseDimension(raw); + rounded.set(name, resolved); + symbolTable.set(`rounded.${name}`, resolved); + } else { + symbolTable.set(`rounded.${name}`, raw); + } + } + } + + // Spacing + if (input.spacing) { + for (const [name, raw] of Object.entries(input.spacing)) { + if (isParseableDimension(raw)) { + const resolved = parseDimension(raw); + spacing.set(name, resolved); + symbolTable.set(`spacing.${name}`, resolved); + } else { + symbolTable.set(`spacing.${name}`, raw); + } + } + } + + // ── 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)) { + 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); + } + } + } + } + + // ── Phase 3: Build components ────────────────────────────────── + const components = new Map(); + if (input.components) { + for (const [compName, props] of Object.entries(input.components)) { + const properties = new Map(); + const unresolvedRefs: string[] = []; + + for (const [propName, rawValue] of Object.entries(props)) { + if (isTokenReference(rawValue)) { + const refPath = rawValue.slice(1, -1); + const resolved = resolveReference(symbolTable, refPath, new Set()); + if (resolved !== null) { + properties.set(propName, resolved); + } else { + unresolvedRefs.push(rawValue); + properties.set(propName, rawValue); + } + } else if (isValidColor(rawValue)) { + properties.set(propName, parseColor(rawValue)); + } else if (isParseableDimension(rawValue)) { + properties.set(propName, parseDimension(rawValue)); + } else { + properties.set(propName, rawValue); + } + } + + components.set(compName, { properties, unresolvedRefs }); + } + } + + return { + success: true, + data: { + name: input.name, + description: input.description, + colors, + typography, + rounded, + spacing, + components, + symbolTable, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } +} + +// ── Pure utility functions ───────────────────────────────────────── + +/** + * Parse a hex color string into a ResolvedColor with RGB + WCAG luminance. + */ +function parseColor(raw: string): ResolvedColor { + let hex = raw; + + // Normalize #RGB to #RRGGBB + if (hex.length === 4) { + hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`; + } + + hex = hex.toLowerCase(); + + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + + const luminance = computeLuminance(r, g, b); + + return { type: 'color', hex, r, g, b, luminance }; +} + +/** + * Compute WCAG 2.1 relative luminance. + * Uses sRGB linearization. + */ +function computeLuminance(r: number, g: number, b: number): number { + const [rLin, gLin, bLin] = [r, g, b].map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rLin! + 0.7152 * gLin! + 0.0722 * bLin!; +} + +/** + * Parse a dimension string like "42px" or "1.5rem". + */ +function parseDimension(raw: string): ResolvedDimension { + const parts = parseDimensionParts(raw); + if (!parts) { + throw new Error(`Invalid dimension: ${raw}`); + } + return { + type: 'dimension', + value: parts.value, + unit: parts.unit, + }; +} + +/** + * Parse a typography properties object into a ResolvedTypography. + */ +function parseTypography(props: Record): ResolvedTypography { + const result: ResolvedTypography = { type: 'typography' }; + + if (typeof props['fontFamily'] === 'string') result.fontFamily = props['fontFamily']; + if (typeof props['fontWeight'] === 'number') result.fontWeight = props['fontWeight']; + if (typeof props['fontFeature'] === 'string') result.fontFeature = props['fontFeature']; + if (typeof props['fontVariation'] === 'string') result.fontVariation = props['fontVariation']; + + const dimensionProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const; + for (const prop of dimensionProps) { + const raw = props[prop]; + if (typeof raw === 'string' && isParseableDimension(raw)) { + result[prop] = parseDimension(raw); + } + } + + return result; +} + +/** + * Resolve a token reference with chained resolution and cycle detection. + * Returns null if the reference cannot be resolved (not found or circular). + */ +function resolveReference( + symbolTable: Map, + path: string, + visited: Set, + depth: number = 0, +): ResolvedValue | null { + if (depth > MAX_REFERENCE_DEPTH) return null; + if (visited.has(path)) return null; // Circular reference + visited.add(path); + + const value = symbolTable.get(path); + if (value === undefined) return null; + + // If the value is itself a reference string, follow the chain + if (typeof value === 'string' && isTokenReference(value)) { + const innerPath = value.slice(1, -1); + return resolveReference(symbolTable, innerPath, visited, depth + 1); + } + + return value; +} + +/** + * WCAG 2.1 contrast ratio between two resolved colors. + */ +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); +} diff --git a/packages/linter/src/model/spec.test.ts b/packages/linter/src/model/spec.test.ts new file mode 100644 index 00000000..fa072a1b --- /dev/null +++ b/packages/linter/src/model/spec.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'bun:test'; +import { isValidColor, isStandardDimension, isParseableDimension, parseDimensionParts, isTokenReference } from './spec.js'; + +describe('isValidColor', () => { + const validColors = ['#ff0000', '#FF0000', '#abc', '#ABC', '#647D66', '#000', '#fff']; + const invalidColors = ['red', 'blue', '#gg0000', '#12345', '647D66', '#1234567', '', '#']; + + it.each(validColors)('accepts valid hex color: %s', (color: string) => { + expect(isValidColor(color)).toBe(true); + }); + + it.each(invalidColors)('rejects invalid color: %s', (color: string) => { + expect(isValidColor(color)).toBe(false); + }); +}); + +describe('isStandardDimension', () => { + const standard = ['12px', '1.5rem', '0px', '42px', '0.75rem', '100px']; + const nonStandard = ['42', 'px', 'rem', '12em', '12vh', '', '12 px', '-0.02em']; + + it.each(standard)('accepts standard dimension: %s', (dim: string) => { + expect(isStandardDimension(dim)).toBe(true); + }); + + it.each(nonStandard)('rejects non-standard dimension: %s', (dim: string) => { + expect(isStandardDimension(dim)).toBe(false); + }); +}); + +describe('isParseableDimension', () => { + const parseable = [ + '12px', '1.5rem', '-0.02em', '100vh', '50%', '0.75rem', '1em', '12vw', + // CSS Level 4 units now in scope + '10cqi', '20lvh', '30dvw', '5cqmin', + ]; + const unparseable = ['42', 'px', 'rem', '', '12 px', 'auto', 'inherit']; + + it.each(parseable)('accepts parseable dimension: %s', (dim: string) => { + expect(isParseableDimension(dim)).toBe(true); + }); + + it.each(unparseable)('rejects unparseable dimension: %s', (dim: string) => { + expect(isParseableDimension(dim)).toBe(false); + }); +}); + +describe('parseDimensionParts', () => { + it('parses standard dimensions', () => { + expect(parseDimensionParts('42px')).toEqual({ value: 42, unit: 'px' }); + expect(parseDimensionParts('1.5rem')).toEqual({ value: 1.5, unit: 'rem' }); + expect(parseDimensionParts('-0.02em')).toEqual({ value: -0.02, unit: 'em' }); + }); + + it('parses leading-zero-free decimals (.5rem)', () => { + expect(parseDimensionParts('.5rem')).toEqual({ value: 0.5, unit: 'rem' }); + expect(parseDimensionParts('-.25em')).toEqual({ value: -0.25, unit: 'em' }); + }); + + it('returns null for bare numbers without a unit', () => { + expect(parseDimensionParts('42')).toBeNull(); + expect(parseDimensionParts('')).toBeNull(); + }); + + it('returns null for CSS keywords', () => { + expect(parseDimensionParts('auto')).toBeNull(); + expect(parseDimensionParts('inherit')).toBeNull(); + }); +}); + +describe('isTokenReference', () => { + it('recognizes curly-brace token references', () => { + expect(isTokenReference('{colors.primary}')).toBe(true); + expect(isTokenReference('{typography.headline-lg}')).toBe(true); + expect(isTokenReference('{colors.primary-60}')).toBe(true); + }); + + it('rejects non-references', () => { + expect(isTokenReference('#ff0000')).toBe(false); + expect(isTokenReference('colors.primary')).toBe(false); + expect(isTokenReference('{}')).toBe(false); + expect(isTokenReference('{ colors.primary }')).toBe(false); + }); +}); diff --git a/packages/linter/src/model/spec.ts b/packages/linter/src/model/spec.ts new file mode 100644 index 00000000..ac11e9be --- /dev/null +++ b/packages/linter/src/model/spec.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; +import type { ParsedDesignSystem } from '../parser/spec.js'; + +// ── RESOLVED VALUE TYPES ─────────────────────────────────────────── +export interface ResolvedColor { + type: 'color'; + hex: string; + r: number; + g: number; + b: number; + /** WCAG relative luminance */ + luminance: number; +} + +export interface ResolvedDimension { + type: 'dimension'; + value: number; + /** The unit string. Standard units are 'px' and 'rem'; others are preserved but flagged by the linter. */ + unit: string; +} + +export interface ResolvedTypography { + type: 'typography'; + fontFamily?: string | undefined; + fontSize?: ResolvedDimension | undefined; + fontWeight?: number | undefined; + lineHeight?: ResolvedDimension | undefined; + letterSpacing?: ResolvedDimension | undefined; + fontFeature?: string | undefined; + fontVariation?: string | undefined; +} + +export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string; + +// ── VALID TYPOGRAPHY PROPERTIES ──────────────────────────────────── +export const VALID_TYPOGRAPHY_PROPS = [ + 'fontFamily', + 'fontSize', + 'fontWeight', + 'lineHeight', + 'letterSpacing', + 'fontFeature', + 'fontVariation', +] as const; + +// ── VALID COMPONENT SUB-TOKENS ───────────────────────────────────── +export const VALID_COMPONENT_SUB_TOKENS = [ + 'backgroundColor', + 'textColor', + 'typography', + 'rounded', + 'padding', + 'size', + 'height', + 'width', +] as const; + +// ── STATE ────────────────────────────────────────────────────────── +export interface DesignSystemState { + name?: string | undefined; + description?: string | undefined; + colors: Map; + typography: Map; + rounded: Map; + spacing: Map; + components: Map; + /** Flat lookup: "colors.primary" → ResolvedColor */ + symbolTable: Map; +} + +export interface ComponentDef { + properties: Map; + /** Unresolved references that failed to resolve */ + unresolvedRefs: string[]; +} + +// ── ERROR CODES ──────────────────────────────────────────────────── +export const ModelErrorCode = z.enum([ + 'INVALID_COLOR', + 'INVALID_DIMENSION', + 'INVALID_TYPOGRAPHY_PROP', + 'UNRESOLVED_REFERENCE', + 'CIRCULAR_REFERENCE', + 'REFERENCE_TO_NON_PRIMITIVE', + 'UNKNOWN_ERROR', +]); + +// ── RESULT ───────────────────────────────────────────────────────── +export type ModelResult = + | { success: true; data: DesignSystemState } + | { + success: false; + error: { + code: z.infer; + message: string; + recoverable: boolean; + }; + }; + +// ── INTERFACE ────────────────────────────────────────────────────── +export interface ModelSpec { + execute(input: ParsedDesignSystem): ModelResult; +} + +// ── VALIDATION HELPERS ───────────────────────────────────────────── + +/** Units the spec formally supports. Adding a new unit = one string. */ +const STANDARD_UNITS = new Set(['px', 'rem']); + +/** + * All known CSS length/percentage units. + * Adding a new CSS unit = one string here. Never edit a regex. + */ +const CSS_UNITS = new Set([ + // Absolute + 'px', 'cm', 'mm', 'in', 'pt', 'pc', + // Relative to font + 'em', 'rem', 'ex', 'ch', 'cap', 'ic', 'lh', 'rlh', + // Viewport — classic + 'vh', 'vw', 'vmin', 'vmax', + // Viewport — dynamic/small/large (CSS Level 4) + 'dvh', 'dvw', 'dvmin', 'dvmax', + 'svh', 'svw', 'svmin', 'svmax', + 'lvh', 'lvw', 'lvmin', 'lvmax', + // Container query units + 'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax', + // Percentage + '%', +]); + +/** + * Parse a dimension string into its numeric value and unit suffix. + * Accepts an optional leading sign and optional decimal (`.5rem` is valid). + * Returns null for non-dimension strings (bare numbers, keywords like `auto`). + */ +export function parseDimensionParts(raw: string): { value: number; unit: string } | null { + const match = raw.match(/^(-?\d*\.?\d+)([a-zA-Z%]+)$/); + if (!match) return null; + const value = parseFloat(match[1]!); + return Number.isNaN(value) ? null : { value, unit: match[2]! }; +} + +/** + * Validate a hex color string. Accepts #RGB and #RRGGBB. + */ +export function isValidColor(raw: string): boolean { + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw); +} + +/** + * Validate a dimension string uses a spec-standard unit (px or rem only). + */ +export function isStandardDimension(raw: string): boolean { + const parts = parseDimensionParts(raw); + return parts !== null && STANDARD_UNITS.has(parts.unit); +} + +/** + * Check if a dimension string is parseable (any known CSS length/percentage unit). + * Adding support for a new unit: add it to CSS_UNITS above. + */ +export function isParseableDimension(raw: string): boolean { + const parts = parseDimensionParts(raw); + return parts !== null && CSS_UNITS.has(parts.unit); +} + +/** + * @deprecated Use isStandardDimension for spec compliance or isParseableDimension for generous parsing. + */ +export const isValidDimension = isStandardDimension; + +/** + * Check if a string is a token reference ({section.token}). + */ +export function isTokenReference(raw: string): boolean { + return /^\{[a-zA-Z0-9._-]+\}$/.test(raw); +} diff --git a/packages/linter/src/parser/handler.test.ts b/packages/linter/src/parser/handler.test.ts new file mode 100644 index 00000000..98a155ea --- /dev/null +++ b/packages/linter/src/parser/handler.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'bun:test'; +import { ParserHandler } from './handler.js'; + +const handler = new ParserHandler(); + +describe('ParserHandler', () => { + // ── Cycle 2: Frontmatter extraction ─────────────────────────────── + describe('frontmatter extraction', () => { + it('extracts YAML from frontmatter delimiters', () => { + const input = `--- +name: Kindred Spirit +colors: + primary: "#647D66" +--- + +Some markdown content here. +`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Kindred Spirit'); + expect(result.data.colors?.['primary']).toBe('#647D66'); + } + }); + }); + + // ── Cycle 3: Code block extraction ──────────────────────────────── + describe('code block extraction', () => { + it('extracts YAML from fenced yaml code blocks', () => { + const input = `# Design System + +\`\`\`yaml +colors: + primary: "#ff0000" + secondary: "#00ff00" +\`\`\` + +Some explanation text. +`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.colors?.['primary']).toBe('#ff0000'); + expect(result.data.colors?.['secondary']).toBe('#00ff00'); + } + }); + }); + + // ── Cycle 4: Merge multiple code blocks ─────────────────────────── + describe('merging multiple code blocks', () => { + it('merges separate YAML blocks into one tree', () => { + const input = `# Colors + +\`\`\`yaml +colors: + primary: "#647D66" +\`\`\` + +# Typography + +\`\`\`yaml +typography: + headline-lg: + fontFamily: Google Sans Display + fontSize: 42px +\`\`\` +`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.colors?.['primary']).toBe('#647D66'); + expect(result.data.typography?.['headline-lg']?.['fontFamily']).toBe('Google Sans Display'); + } + }); + }); + + // ── Cycle 5: Duplicate section detection ────────────────────────── + describe('duplicate section detection', () => { + it('returns DUPLICATE_SECTION when same top-level key appears in multiple blocks', () => { + const input = ` +\`\`\`yaml +colors: + primary: "#ff0000" +\`\`\` + +\`\`\`yaml +colors: + secondary: "#00ff00" +\`\`\` +`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('DUPLICATE_SECTION'); + expect(result.error.message).toContain('colors'); + } + }); + }); + + // ── Cycle 6: Malformed YAML ─────────────────────────────────────── + describe('malformed YAML', () => { + it('returns YAML_PARSE_ERROR on invalid YAML syntax', () => { + const input = `--- +colors: + primary: "#ff0000" + - this is invalid +---`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('YAML_PARSE_ERROR'); + } + }); + + it('returns NO_YAML_FOUND when no YAML content exists', () => { + const input = `# Just a heading + +Some markdown text with no YAML blocks. +`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('NO_YAML_FOUND'); + } + }); + }); +}); diff --git a/packages/linter/src/parser/handler.ts b/packages/linter/src/parser/handler.ts new file mode 100644 index 00000000..7f8871be --- /dev/null +++ b/packages/linter/src/parser/handler.ts @@ -0,0 +1,183 @@ +import YAML from 'yaml'; +import type { ParserSpec, ParserInput, ParserResult, ParsedDesignSystem, SourceLocation } from './spec.js'; + +/** + * Extracts and parses YAML design tokens from DESIGN.md content. + * Supports two embedding modes: frontmatter (---) and fenced yaml code blocks. + * Never throws — all errors returned as ParserResult failures. + */ +export class ParserHandler implements ParserSpec { + execute(input: ParserInput): ParserResult { + try { + const { content } = input; + + // Attempt frontmatter extraction first + const frontmatterYaml = this.extractFrontmatter(content); + if (frontmatterYaml !== null) { + return this.parseYamlContent(frontmatterYaml, 'frontmatter'); + } + + // Fall back to code block extraction + const codeBlocks = this.extractCodeBlocks(content); + if (codeBlocks.length === 0) { + return { + success: false, + error: { + code: 'NO_YAML_FOUND', + message: 'No YAML content found. Expected frontmatter (---) or fenced yaml code blocks.', + recoverable: true, + }, + }; + } + + return this.mergeCodeBlocks(codeBlocks); + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } + + /** + * Extract YAML from frontmatter delimiters (--- ... ---). + * Returns null if no frontmatter is found. + */ + private extractFrontmatter(content: string): string | null { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match?.[1]) return null; + return match[1]; + } + + /** + * Extract all fenced yaml code blocks from the markdown content. + * Returns an array of { yaml: string, startLine: number } objects. + */ + private extractCodeBlocks(content: string): Array<{ yaml: string; blockIndex: number; startLine: number }> { + const blocks: Array<{ yaml: string; blockIndex: number; startLine: number }> = []; + const regex = /```yaml\r?\n([\s\S]*?)```/g; + let match: RegExpExecArray | null; + let blockIndex = 0; + + while ((match = regex.exec(content)) !== null) { + const yamlContent = match[1]; + if (yamlContent) { + const startLine = content.substring(0, match.index).split('\n').length + 1; + blocks.push({ yaml: yamlContent, blockIndex, startLine }); + blockIndex++; + } + } + + return blocks; + } + + /** + * Parse a single YAML string into a ParsedDesignSystem. + */ + private parseYamlContent(yamlStr: string, block: 'frontmatter' | number): ParserResult { + try { + const parsed = YAML.parse(yamlStr) as Record; + if (!parsed || typeof parsed !== 'object') { + return { + success: false, + error: { + code: 'YAML_PARSE_ERROR', + message: 'YAML content did not parse to an object.', + recoverable: true, + }, + }; + } + + const sourceMap = new Map(); + // Build source map for top-level keys + for (const key of Object.keys(parsed)) { + sourceMap.set(key, { line: 0, column: 0, block }); + } + + return { + success: true, + data: this.toDesignSystem(parsed, sourceMap), + }; + } catch (error) { + return { + success: false, + error: { + code: 'YAML_PARSE_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: true, + }, + }; + } + } + + /** + * Merge multiple code blocks into a single ParsedDesignSystem. + * Detects duplicate top-level sections across blocks. + */ + private mergeCodeBlocks(blocks: Array<{ yaml: string; blockIndex: number; startLine: number }>): ParserResult { + const merged: Record = {}; + const sourceMap = new Map(); + const seenSections = new Map(); // section → blockIndex + + for (const block of blocks) { + let parsed: Record; + try { + parsed = YAML.parse(block.yaml) as Record; + if (!parsed || typeof parsed !== 'object') continue; + } catch (error) { + return { + success: false, + error: { + code: 'YAML_PARSE_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: true, + }, + }; + } + + // Check for duplicate top-level sections + for (const key of Object.keys(parsed)) { + const previousBlock = seenSections.get(key); + if (previousBlock !== undefined) { + return { + success: false, + error: { + code: 'DUPLICATE_SECTION', + message: `Section '${key}' is defined in both code block ${previousBlock + 1} and code block ${block.blockIndex + 1}.`, + recoverable: true, + }, + }; + } + seenSections.set(key, block.blockIndex); + sourceMap.set(key, { line: block.startLine, column: 0, block: block.blockIndex }); + } + + Object.assign(merged, parsed); + } + + return { + success: true, + data: this.toDesignSystem(merged, sourceMap), + }; + } + + /** + * Map a raw parsed object to the ParsedDesignSystem interface. + */ + private toDesignSystem(raw: Record, sourceMap: Map): ParsedDesignSystem { + return { + name: typeof raw['name'] === 'string' ? raw['name'] : undefined, + description: typeof raw['description'] === 'string' ? raw['description'] : undefined, + colors: raw['colors'] as Record | undefined, + typography: raw['typography'] as Record> | undefined, + rounded: raw['rounded'] as Record | undefined, + spacing: raw['spacing'] as Record | undefined, + components: raw['components'] as Record> | undefined, + sourceMap, + }; + } +} diff --git a/packages/linter/src/parser/spec.test.ts b/packages/linter/src/parser/spec.test.ts new file mode 100644 index 00000000..67ae2d49 --- /dev/null +++ b/packages/linter/src/parser/spec.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'bun:test'; +import { ParserInputSchema } from './spec.js'; + +describe('ParserInputSchema', () => { + it('rejects empty content', () => { + const result = ParserInputSchema.safeParse({ content: '' }); + expect(result.success).toBe(false); + }); + + it('accepts non-empty content', () => { + const result = ParserInputSchema.safeParse({ content: '---\nname: test\n---' }); + expect(result.success).toBe(true); + }); + + it('rejects missing content field', () => { + const result = ParserInputSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/linter/src/parser/spec.ts b/packages/linter/src/parser/spec.ts new file mode 100644 index 00000000..3caa4998 --- /dev/null +++ b/packages/linter/src/parser/spec.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +// ── INPUT ────────────────────────────────────────────────────────── +export const ParserInputSchema = z.object({ + /** Raw DESIGN.md content (or standalone YAML string) */ + content: z.string().min(1, 'Content must not be empty'), +}); +export type ParserInput = z.infer; + +// ── ERROR CODES ──────────────────────────────────────────────────── +export const ParserErrorCode = z.enum([ + 'EMPTY_CONTENT', + 'NO_YAML_FOUND', + 'YAML_PARSE_ERROR', + 'DUPLICATE_SECTION', + 'UNKNOWN_ERROR', +]); + +// ── OUTPUT ────────────────────────────────────────────────────────── +export interface SourceLocation { + line: number; + column: number; + block: 'frontmatter' | number; +} + +/** Raw, unresolved parsed output — mirrors the YAML schema */ +export interface ParsedDesignSystem { + name?: string | undefined; + description?: string | undefined; + colors?: Record | undefined; + typography?: Record> | undefined; + rounded?: Record | undefined; + spacing?: Record | undefined; + components?: Record> | undefined; + sourceMap: Map; +} + +// ── RESULT ───────────────────────────────────────────────────────── +export type ParserResult = + | { success: true; data: ParsedDesignSystem } + | { + success: false; + error: { + code: z.infer; + message: string; + recoverable: boolean; + }; + }; + +// ── INTERFACE ────────────────────────────────────────────────────── +export interface ParserSpec { + execute(input: ParserInput): ParserResult; +} diff --git a/packages/linter/src/tailwind/handler.test.ts b/packages/linter/src/tailwind/handler.test.ts new file mode 100644 index 00000000..e8d2dac5 --- /dev/null +++ b/packages/linter/src/tailwind/handler.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'bun:test'; +import { TailwindEmitterHandler } from './handler.js'; +import { ModelHandler } from '../model/handler.js'; +import type { ParsedDesignSystem } from '../parser/spec.js'; + +const emitter = new TailwindEmitterHandler(); +const modelHandler = new ModelHandler(); + +function buildState(overrides: Partial = {}) { + const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; + const result = modelHandler.execute(parsed); + if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); + return result.data; +} + +describe('TailwindEmitterHandler', () => { + // ── Cycle 22: Colors map to theme.extend.colors ───────────────── + describe('colors mapping', () => { + it('maps resolved colors to theme.extend.colors', () => { + const state = buildState({ + colors: { primary: '#647D66', secondary: '#ff0000' }, + }); + const config = emitter.execute(state); + expect(config.theme.extend.colors?.['primary']).toBe('#647d66'); + expect(config.theme.extend.colors?.['secondary']).toBe('#ff0000'); + }); + }); + + // ── Cycle 23: Typography maps to fontFamily + fontSize ────────── + describe('typography mapping', () => { + it('maps typography scales to fontFamily and fontSize', () => { + const state = buildState({ + typography: { + 'headline-lg': { + fontFamily: 'Google Sans Display', + fontSize: '42px', + fontWeight: 500, + lineHeight: '50px', + letterSpacing: '1.2px', + }, + 'body-lg': { + fontFamily: 'Roboto', + fontSize: '14px', + fontWeight: 400, + lineHeight: '20px', + }, + }, + }); + const config = emitter.execute(state); + + // fontFamily + expect(config.theme.extend.fontFamily?.['headline-lg']).toContain('Google Sans Display'); + expect(config.theme.extend.fontFamily?.['body-lg']).toContain('Roboto'); + + // fontSize with metadata tuple + const hlFontSize = config.theme.extend.fontSize?.['headline-lg']; + expect(hlFontSize).toBeDefined(); + expect(hlFontSize?.[0]).toBe('42px'); + expect(hlFontSize?.[1]?.['lineHeight']).toBe('50px'); + expect(hlFontSize?.[1]?.['letterSpacing']).toBe('1.2px'); + }); + }); + + // ── Cycle 24: Rounded + spacing map correctly ─────────────────── + describe('dimensions mapping', () => { + it('maps rounded to borderRadius and spacing to spacing', () => { + const state = buildState({ + rounded: { regular: '4px', lg: '8px', full: '9999px' }, + spacing: { 'gutter-s': '8px', 'gutter-l': '16px' }, + }); + const config = emitter.execute(state); + + expect(config.theme.extend.borderRadius?.['regular']).toBe('4px'); + expect(config.theme.extend.borderRadius?.['lg']).toBe('8px'); + expect(config.theme.extend.borderRadius?.['full']).toBe('9999px'); + + expect(config.theme.extend.spacing?.['gutter-s']).toBe('8px'); + expect(config.theme.extend.spacing?.['gutter-l']).toBe('16px'); + }); + }); + + // ── Empty state produces empty config ───────────────────────────── + describe('empty state', () => { + it('produces a valid config with empty extend sections', () => { + const state = buildState({}); + const config = emitter.execute(state); + expect(config.theme.extend).toBeDefined(); + }); + }); +}); diff --git a/packages/linter/src/tailwind/handler.ts b/packages/linter/src/tailwind/handler.ts new file mode 100644 index 00000000..02b20594 --- /dev/null +++ b/packages/linter/src/tailwind/handler.ts @@ -0,0 +1,66 @@ +import type { TailwindEmitterSpec, TailwindConfig } from './spec.js'; +import type { DesignSystemState, ResolvedDimension } from '../model/spec.js'; + +/** + * Pure function mapping DesignSystemState → Tailwind theme.extend config. + * No side effects. + */ +export class TailwindEmitterHandler implements TailwindEmitterSpec { + execute(state: DesignSystemState): TailwindConfig { + return { + theme: { + extend: { + colors: this.mapColors(state), + fontFamily: this.mapFontFamilies(state), + fontSize: this.mapFontSizes(state), + borderRadius: this.mapDimensions(state.rounded), + spacing: this.mapDimensions(state.spacing), + }, + }, + }; + } + + private mapColors(state: DesignSystemState): Record { + const result: Record = {}; + for (const [name, color] of state.colors) { + result[name] = color.hex; + } + return result; + } + + private mapFontFamilies(state: DesignSystemState): Record { + const result: Record = {}; + for (const [name, typo] of state.typography) { + if (typo.fontFamily) { + result[name] = [typo.fontFamily]; + } + } + return result; + } + + private mapFontSizes(state: DesignSystemState): Record]> { + const result: Record]> = {}; + for (const [name, typo] of state.typography) { + if (typo.fontSize) { + const meta: Record = {}; + if (typo.lineHeight) meta['lineHeight'] = this.dimToString(typo.lineHeight); + if (typo.letterSpacing) meta['letterSpacing'] = this.dimToString(typo.letterSpacing); + if (typo.fontWeight !== undefined) meta['fontWeight'] = String(typo.fontWeight); + result[name] = [this.dimToString(typo.fontSize), meta]; + } + } + return result; + } + + private mapDimensions(dims: Map): Record { + const result: Record = {}; + for (const [name, dim] of dims) { + result[name] = this.dimToString(dim); + } + return result; + } + + private dimToString(dim: { value: number; unit: string }): string { + return `${dim.value}${dim.unit}`; + } +} diff --git a/packages/linter/src/tailwind/spec.ts b/packages/linter/src/tailwind/spec.ts new file mode 100644 index 00000000..26300b3f --- /dev/null +++ b/packages/linter/src/tailwind/spec.ts @@ -0,0 +1,19 @@ +import type { DesignSystemState } from '../model/spec.js'; + +// ── TAILWIND CONFIG ──────────────────────────────────────────────── +export interface TailwindConfig { + theme: { + extend: { + colors?: Record; + fontFamily?: Record; + fontSize?: Record]>; + borderRadius?: Record; + spacing?: Record; + }; + }; +} + +// ── INTERFACE ────────────────────────────────────────────────────── +export interface TailwindEmitterSpec { + execute(state: DesignSystemState): TailwindConfig; +} diff --git a/packages/linter/tsconfig.build.json b/packages/linter/tsconfig.build.json new file mode 100644 index 00000000..0d90f99d --- /dev/null +++ b/packages/linter/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.test.ts", + "src/fixtures" + ] +} diff --git a/packages/linter/tsconfig.json b/packages/linter/tsconfig.json new file mode 100644 index 00000000..376bad91 --- /dev/null +++ b/packages/linter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["bun", "node"] + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..9ccc8f6b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..56586060 --- /dev/null +++ b/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "test": { + "dependsOn": ["build"] + }, + "lint": {} + } +} From 0fecea4ff2c51d92cfddea4c41580f914455daca Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 10 Apr 2026 18:32:43 -0400 Subject: [PATCH 2/6] package smoke-test --- packages/linter/package.json | 4 +- packages/linter/scripts/check-package.ts | 296 +++++++++++++++++++++++ 2 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 packages/linter/scripts/check-package.ts diff --git a/packages/linter/package.json b/packages/linter/package.json index 8389f82e..9da6a413 100644 --- a/packages/linter/package.json +++ b/packages/linter/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", + "files": ["dist"], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -14,7 +15,8 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "test": "bun test src", - "lint": "echo 'no linter configured yet'" + "lint": "echo 'no linter configured yet'", + "check-package": "bun run scripts/check-package.ts" }, "dependencies": { "yaml": "^2.7.1", diff --git a/packages/linter/scripts/check-package.ts b/packages/linter/scripts/check-package.ts new file mode 100644 index 00000000..893ef41c --- /dev/null +++ b/packages/linter/scripts/check-package.ts @@ -0,0 +1,296 @@ +#!/usr/bin/env bun +/** + * Package verification script for @google/design.md + * + * Runs 17 checks across 4 phases to ensure the package is correctly + * structured for npm publication. Exit code 0 = all pass, 1 = failures. + * + * Usage: bun run scripts/check-package.ts + */ + +import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join, resolve } from 'path'; +import { execSync } from 'child_process'; +import { Glob, $ } from 'bun'; +import { tmpdir } from 'os'; + +// ── Helpers ──────────────────────────────────────────────────────── + +const ROOT = resolve(import.meta.dir, '..'); +const PKG_PATH = join(ROOT, 'package.json'); + +let passed = 0; +let failed = 0; + +function pass(label: string) { + console.log(` ✅ ${label}`); + passed++; +} + +function fail(label: string, detail?: string) { + console.error(` ❌ ${label}`); + if (detail) console.error(` → ${detail}`); + failed++; +} + +function check(label: string, ok: boolean, detail?: string) { + if (ok) pass(label); + else fail(label, detail); +} + +function heading(title: string) { + console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 56 - title.length))}`); +} + +function readPkg(): Record { + return JSON.parse(readFileSync(PKG_PATH, 'utf-8')); +} + +function exec(cmd: string, opts?: { cwd?: string }): { ok: boolean; stdout: string } { + try { + const stdout = execSync(cmd, { + cwd: opts?.cwd ?? ROOT, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${process.env.PATH ?? ''}` }, + }); + return { ok: true, stdout }; + } catch (e: unknown) { + const err = e as { stdout?: string }; + return { ok: false, stdout: err.stdout ?? '' }; + } +} + +// ── Phase 1: Pre-pack validation ─────────────────────────────────── + +function phase1_config() { + heading('Phase 1a: Package.json validation'); + + const pkg = readPkg(); + + // 1. files field exists + const files = pkg.files as string[] | undefined; + check('#1 `files` field exists', Array.isArray(files) && files.length > 0, + 'Add a "files" array to package.json to control what ships'); + + // 2. exports structure + const exports = pkg.exports as Record> | undefined; + check('#2 `exports["."]` defined', !!exports?.['.'], + 'Missing exports["."] in package.json'); + + if (exports?.['.']) { + check('#3 `exports["."].types` field exists', !!exports['.'].types, + 'Missing types condition in exports["."]'); + } else { + fail('#3 `exports["."].types` field exists', 'Skipped — no exports'); + } + + // 4. main field + const main = pkg.main as string | undefined; + check('#4 `main` field exists', !!main, + 'Missing "main" field in package.json'); + + // 5. types field + const types = pkg.types as string | undefined; + check('#5 `types` field exists', !!types, + 'Missing "types" field in package.json'); +} + +function phase1_paths() { + heading('Phase 1b: Path resolution (post-build)'); + + const pkg = readPkg(); + const exports = pkg.exports as Record> | undefined; + + if (exports?.['.']) { + const importPath = join(ROOT, exports['.'].import); + const typesPath = join(ROOT, exports['.'].types); + check('#2b `exports["."].import` resolves', existsSync(importPath), + `Missing: ${exports['.'].import}`); + check('#3b `exports["."].types` resolves', existsSync(typesPath), + `Missing: ${exports['.'].types}`); + } + + const main = pkg.main as string | undefined; + check('#4b `main` resolves', !!main && existsSync(join(ROOT, main)), + `Missing: ${main}`); + + const types = pkg.types as string | undefined; + check('#5b `types` resolves', !!types && existsSync(join(ROOT, types)), + `Missing: ${types}`); +} + +// ── Phase 2: Clean build ─────────────────────────────────────────── + +function phase2() { + heading('Phase 2: Clean build'); + + // 6. Clean dist + const distPath = join(ROOT, 'dist'); + if (existsSync(distPath)) { + rmSync(distPath, { recursive: true, force: true }); + } + check('#6 Clean dist', !existsSync(distPath)); + + // 7. Build + const build = exec('tsc -p tsconfig.build.json'); + check('#7 Build succeeds', build.ok, 'tsc exited with non-zero'); + + if (!existsSync(distPath)) { + fail('#8 No test files in dist', 'dist/ does not exist after build'); + fail('#9 No fixture files in dist', 'dist/ does not exist after build'); + return; + } + + // 8. No test files in dist + const testGlob = new Glob('**/*.test.*'); + const testFiles = Array.from(testGlob.scanSync({ cwd: distPath, absolute: false })); + check('#8 No test files in dist', testFiles.length === 0, + `Found: ${testFiles.join(', ')}`); + + // 9. No fixture files in dist + const fixtureGlob = new Glob('**/fixtures/**'); + const fixtureFiles = Array.from(fixtureGlob.scanSync({ cwd: distPath, absolute: false })); + check('#9 No fixture files in dist', fixtureFiles.length === 0, + `Found: ${fixtureFiles.join(', ')}`); +} + +// ── Phase 3: Pack audit ──────────────────────────────────────────── + +function phase3() { + heading('Phase 3: Pack audit'); + + // 10. npm pack --dry-run + const pack = exec('npm pack --dry-run --json 2>/dev/null'); + let fileList: string[] = []; + + if (pack.ok) { + try { + const parsed = JSON.parse(pack.stdout); + const files = (parsed[0]?.files ?? parsed.files ?? []) as Array<{ path: string }>; + fileList = files.map((f) => f.path); + } catch { + // Fallback: parse the non-JSON dry-run output + const lines = pack.stdout.split('\n'); + fileList = lines + .map((l) => l.replace(/^npm notice\s+\d+[\w.]+\s+/, '').trim()) + .filter((l) => l.includes('/') || l.endsWith('.js') || l.endsWith('.json')); + } + } + + check('#10 `npm pack --dry-run` succeeds', pack.ok && fileList.length > 0, + 'npm pack failed or returned empty file list'); + + if (fileList.length === 0) { + fail('#11 No source .ts files in tarball', 'Skipped — no file list'); + fail('#12 No test files in tarball', 'Skipped — no file list'); + fail('#13 No config files in tarball', 'Skipped — no file list'); + fail('#14 No fixtures in tarball', 'Skipped — no file list'); + fail('#15 Entry point in tarball', 'Skipped — no file list'); + return; + } + + // 11. No source .ts files (allow .d.ts and .d.ts.map) + const rawTs = fileList.filter( + (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.d.ts.map') + ); + check('#11 No source .ts files in tarball', rawTs.length === 0, + `Found: ${rawTs.join(', ')}`); + + // 12. No test files + const testInPack = fileList.filter((f) => f.includes('.test.')); + check('#12 No test files in tarball', testInPack.length === 0, + `Found: ${testInPack.join(', ')}`); + + // 13. No config files + const configInPack = fileList.filter((f) => f.includes('tsconfig')); + check('#13 No config files in tarball', configInPack.length === 0, + `Found: ${configInPack.join(', ')}`); + + // 14. No fixtures + const fixturesInPack = fileList.filter((f) => f.includes('fixtures')); + check('#14 No fixtures in tarball', fixturesInPack.length === 0, + `Found: ${fixturesInPack.join(', ')}`); + + // 15. Entry point present + const hasIndex = fileList.some((f) => f.includes('dist/index.js')); + const hasTypes = fileList.some((f) => f.includes('dist/index.d.ts')); + check('#15 Entry point present in tarball', hasIndex && hasTypes, + `index.js: ${hasIndex}, index.d.ts: ${hasTypes}`); +} + +// ── Phase 4: Consumer smoke test ─────────────────────────────────── + +function phase4() { + heading('Phase 4: Consumer smoke test'); + + const distIndex = join(ROOT, 'dist', 'index.js'); + const distTypes = join(ROOT, 'dist', 'index.d.ts'); + + // 16. Import resolution (ESM) + const tmpDir = mkdtempSync(join(tmpdir(), 'check-pkg-')); + const smokeFile = join(tmpDir, 'smoke.mjs'); + writeFileSync( + smokeFile, + `import { lint } from '${distIndex}';\n` + + `if (typeof lint !== 'function') { process.exit(1); }\n` + + `console.log('ok');\n` + ); + const importCheck = exec(`node ${smokeFile}`); + check('#16 Import resolution (ESM)', importCheck.ok && importCheck.stdout.trim() === 'ok', + 'Could not import lint() from dist/index.js'); + + // 17. Type declarations exist and export lint + const dtsContent = existsSync(distTypes) ? readFileSync(distTypes, 'utf-8') : ''; + const hasLintExport = dtsContent.includes('export') && dtsContent.includes('lint'); + const hasLintReportType = dtsContent.includes('LintReport'); + check('#17 Type declarations valid', + hasLintExport && hasLintReportType, + `index.d.ts missing lint export or LintReport type`); + + // 18. Runtime sanity + const sanityFile = join(tmpDir, 'sanity.mjs'); + writeFileSync( + sanityFile, + `import { lint } from '${distIndex}';\n` + + `const result = lint('---\\nname: Test\\ncolors:\\n primary: "#ff0000"\\n---');\n` + + `const keys = Object.keys(result).sort().join(',');\n` + + `const expected = 'designSystem,diagnostics,summary,tailwindConfig';\n` + + `if (keys !== expected) {\n` + + ` console.error('Shape mismatch:', keys);\n` + + ` process.exit(1);\n` + + `}\n` + + `if (typeof result.designSystem !== 'object') { process.exit(1); }\n` + + `if (!Array.isArray(result.diagnostics)) { process.exit(1); }\n` + + `if (typeof result.summary.errors !== 'number') { process.exit(1); }\n` + + `console.log('ok');\n` + ); + const sanityCheck = exec(`node ${sanityFile}`); + check('#18 Runtime sanity', sanityCheck.ok && sanityCheck.stdout.trim() === 'ok', + 'lint() did not return expected shape'); + + // Cleanup + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +// ── Main ─────────────────────────────────────────────────────────── + +console.log('🔍 Package verification: @google/design.md\n'); + +phase1_config(); +phase2(); +phase1_paths(); +phase3(); +phase4(); + +console.log(`\n${'═'.repeat(60)}`); +console.log(` ✅ ${passed} passed ❌ ${failed} failed`); +console.log(`${'═'.repeat(60)}\n`); + +if (failed > 0) { + process.exit(1); +} From 5ea6e80c38bbc7f6e5efd0923fcf95f3318101f0 Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 10 Apr 2026 19:29:38 -0400 Subject: [PATCH 3/6] feat(linter): refactor modeler to emit diagnostics and remove redundant rules --- .gitignore | 1 + packages/linter/package.json | 12 +- packages/linter/src/fixture.test.ts | 8 +- packages/linter/src/index.ts | 2 - packages/linter/src/lint.ts | 24 +-- packages/linter/src/linter/handler.test.ts | 64 +++----- packages/linter/src/linter/rules/index.ts | 6 - .../src/linter/rules/invalid-color.test.ts | 24 --- .../linter/src/linter/rules/invalid-color.ts | 23 --- .../linter/rules/non-standard-unit.test.ts | 29 ---- .../src/linter/rules/non-standard-unit.ts | 50 ------ .../linter/src/linter/rules/test-helpers.ts | 7 +- packages/linter/src/linter/runner.test.ts | 19 ++- packages/linter/src/linter/spec.ts | 14 +- packages/linter/src/model/handler.test.ts | 143 +++++++++++------- packages/linter/src/model/handler.ts | 111 +++++++++++--- packages/linter/src/model/spec.ts | 23 +-- packages/linter/src/tailwind/handler.test.ts | 7 +- 18 files changed, 260 insertions(+), 307 deletions(-) delete mode 100644 packages/linter/src/linter/rules/invalid-color.test.ts delete mode 100644 packages/linter/src/linter/rules/invalid-color.ts delete mode 100644 packages/linter/src/linter/rules/non-standard-unit.test.ts delete mode 100644 packages/linter/src/linter/rules/non-standard-unit.ts diff --git a/.gitignore b/.gitignore index 5c4f0efa..fdfc187c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ .env *.local bun.lockb +*.tgz diff --git a/packages/linter/package.json b/packages/linter/package.json index 9da6a413..3830f19a 100644 --- a/packages/linter/package.json +++ b/packages/linter/package.json @@ -3,7 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -25,5 +27,9 @@ "devDependencies": { "@types/bun": "^1.3.11", "@types/node": "^20.11.24" - } -} \ No newline at end of file + }, + "description": "", + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/linter/src/fixture.test.ts b/packages/linter/src/fixture.test.ts index 5169ca70..03687bd5 100644 --- a/packages/linter/src/fixture.test.ts +++ b/packages/linter/src/fixture.test.ts @@ -35,11 +35,11 @@ describe('Fixture Test', () => { expect(displayLg?.letterSpacing?.value).toBe(-0.02); expect(displayLg?.letterSpacing?.unit).toBe('em'); - // Check lint results — should have W4 warning for em units - const nonStdWarnings = result.diagnostics.filter( - (d: { severity: string; message: string }) => d.severity === 'warning' && d.message.includes('non-standard') + // Check lint results — should have error for em units from modeler + const unitErrors = result.diagnostics.filter( + (d: { severity: string; message: string }) => d.severity === 'error' && d.message.includes('invalid unit') ); - expect(nonStdWarnings.length).toBeGreaterThan(0); + expect(unitErrors.length).toBeGreaterThan(0); // We expect at least the summary info expect(result.summary.infos).toBeGreaterThan(0); diff --git a/packages/linter/src/index.ts b/packages/linter/src/index.ts index 0faaec8f..c04cf4fc 100644 --- a/packages/linter/src/index.ts +++ b/packages/linter/src/index.ts @@ -20,12 +20,10 @@ export { DEFAULT_RULES } from './linter/rules/index.js'; export type { LintRule } from './linter/rules/types.js'; export type { GradedTokenEdits, TokenEditEntry } from './linter/spec.js'; export { - invalidColor, brokenRef, missingPrimary, contrastCheck, orphanedTokens, - nonStandardUnit, tokenSummary, missingSections, } from './linter/rules/index.js'; diff --git a/packages/linter/src/lint.ts b/packages/linter/src/lint.ts index 44f4c11a..0bf1ddea 100644 --- a/packages/linter/src/lint.ts +++ b/packages/linter/src/lint.ts @@ -44,18 +44,22 @@ export function lint(content: string, options?: LintOptions): LintReport { throw new Error(`Parse failed: ${parseResult.error.message}`); } - const modelResult = model.execute(parseResult.data); - if (!modelResult.success) { - throw new Error(`Model build failed: ${modelResult.error.message}`); - } + const { designSystem, diagnostics: modelDiagnostics } = model.execute(parseResult.data); + const lintResult = runLinter(designSystem, options?.rules); + const tailwindConfig = tailwind.execute(designSystem); - const lintResult = runLinter(modelResult.data, options?.rules); - const tailwindConfig = tailwind.execute(modelResult.data); + const diagnostics = [...modelDiagnostics, ...lintResult.diagnostics]; + const summary = { + errors: modelDiagnostics.filter((d) => d.severity === 'error').length + lintResult.summary.errors, + warnings: modelDiagnostics.filter((d) => d.severity === 'warning').length + lintResult.summary.warnings, + infos: modelDiagnostics.filter((d) => d.severity === 'info').length + lintResult.summary.infos, + }; return { - designSystem: modelResult.data, - diagnostics: lintResult.diagnostics, - summary: lintResult.summary, - tailwindConfig: tailwindConfig, + designSystem, + diagnostics, + summary, + tailwindConfig, }; + } diff --git a/packages/linter/src/linter/handler.test.ts b/packages/linter/src/linter/handler.test.ts index f80319f1..62415f6a 100644 --- a/packages/linter/src/linter/handler.test.ts +++ b/packages/linter/src/linter/handler.test.ts @@ -12,21 +12,15 @@ const modelHandler = new ModelHandler(); function buildState(overrides: Partial = {}): DesignSystemState { const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; const result = modelHandler.execute(parsed); - if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); - return result.data; + const hasErrors = result.diagnostics.some(d => d.severity === 'error'); + if (hasErrors) { + throw new Error(`Model build failed: ${result.diagnostics.map(d => d.message).join(', ')}`); + } + return result.designSystem; } describe('LinterHandler', () => { - // ── Cycle 14: E1 — Invalid color emits error ───────────────────── - describe('E1: invalid color format', () => { - it('emits error for non-hex color values', () => { - const state = buildState({ colors: { accent: 'red' } }); - const result = linter.lint(state); - const errors = result.diagnostics.filter((d: Diagnostic) => d.severity === 'error'); - expect(errors.length).toBeGreaterThan(0); - expect(errors.some((d: Diagnostic) => d.message.includes('red') && d.message.includes('hex'))).toBe(true); - }); - }); + // ── Cycle 15: E3 — Broken reference emits error ────────────────── describe('E3: broken token reference', () => { @@ -127,38 +121,7 @@ describe('LinterHandler', () => { }); }); - // ── W4: Non-standard dimension unit emits warning ──────────────── - describe('W4: non-standard dimension unit', () => { - it('emits warning when typography uses em unit', () => { - const state = buildState({ - colors: { primary: '#000000' }, - typography: { - 'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02em' }, - }, - }); - const result = linter.lint(state); - const warnings = result.diagnostics.filter( - (d: Diagnostic) => d.severity === 'warning' && d.message.includes('non-standard') - ); - expect(warnings.length).toBeGreaterThan(0); - expect(warnings[0]!.message).toMatch(/em/); - expect(warnings[0]!.message).toMatch(/px|rem/); - }); - it('does NOT emit warning for standard px/rem units', () => { - const state = buildState({ - colors: { primary: '#000000' }, - typography: { - 'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '1.2px' }, - }, - }); - const result = linter.lint(state); - const nonStdWarnings = result.diagnostics.filter( - (d: Diagnostic) => d.severity === 'warning' && d.message.includes('non-standard') - ); - expect(nonStdWarnings.length).toBe(0); - }); - }); // ── Cycle 19: I1 — Token count summary emits info ──────────────── describe('I1: token count summary', () => { @@ -205,21 +168,25 @@ describe('LinterHandler', () => { it('groups diagnostics into fixes, improvements, and suggestions', () => { const state = buildState({ colors: { - accent: 'red', // error: invalid color + primary: '#647D66', secondary: '#ffff00', white: '#ffffff', }, components: { 'button-bad': { backgroundColor: '{colors.secondary}', - textColor: '{colors.white}', // warning: low contrast + textColor: '{colors.white}', }, + 'button-broken': { + backgroundColor: '{colors.nonexistent}', + textColor: '{colors.white}', + } }, }); const graded = linter.preEvaluate(state); expect(graded.fixes.length).toBeGreaterThan(0); expect(graded.improvements.length).toBeGreaterThan(0); - expect(graded.suggestions.length).toBeGreaterThan(0); // at least the summary info + expect(graded.suggestions.length).toBeGreaterThan(0); }); }); @@ -227,7 +194,10 @@ describe('LinterHandler', () => { describe('summary counts', () => { it('correctly counts errors, warnings, and infos', () => { const state = buildState({ - colors: { accent: 'red' }, + colors: { primary: '#ff0000' }, + components: { + 'card': { backgroundColor: '{colors.nonexistent}' } + } }); const result = linter.lint(state); expect(result.summary.errors).toBe(result.diagnostics.filter((d: Diagnostic) => d.severity === 'error').length); diff --git a/packages/linter/src/linter/rules/index.ts b/packages/linter/src/linter/rules/index.ts index 3e146336..6bdfa963 100644 --- a/packages/linter/src/linter/rules/index.ts +++ b/packages/linter/src/linter/rules/index.ts @@ -1,32 +1,26 @@ import type { LintRule } from './types.js'; -import { invalidColor } from './invalid-color.js'; import { brokenRef } from './broken-ref.js'; import { missingPrimary } from './missing-primary.js'; import { contrastCheck } from './contrast-ratio.js'; import { orphanedTokens } from './orphaned-tokens.js'; -import { nonStandardUnit } from './non-standard-unit.js'; import { tokenSummary } from './token-summary.js'; import { missingSections } from './missing-sections.js'; /** The default set of lint rules, executed in order. */ export const DEFAULT_RULES: LintRule[] = [ - invalidColor, brokenRef, missingPrimary, contrastCheck, orphanedTokens, - nonStandardUnit, tokenSummary, missingSections, ]; // Re-export individual rules for selective composition -export { invalidColor } from './invalid-color.js'; export { brokenRef } from './broken-ref.js'; export { missingPrimary } from './missing-primary.js'; export { contrastCheck } from './contrast-ratio.js'; export { orphanedTokens } from './orphaned-tokens.js'; -export { nonStandardUnit } from './non-standard-unit.js'; export { tokenSummary } from './token-summary.js'; export { missingSections } from './missing-sections.js'; export type { LintRule } from './types.js'; diff --git a/packages/linter/src/linter/rules/invalid-color.test.ts b/packages/linter/src/linter/rules/invalid-color.test.ts deleted file mode 100644 index 168298bd..00000000 --- a/packages/linter/src/linter/rules/invalid-color.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { invalidColor } from './invalid-color.js'; -import { buildState } from './test-helpers.js'; - -describe('invalidColor', () => { - it('emits error for non-hex color values', () => { - const state = buildState({ colors: { accent: 'red' } }); - const diagnostics = invalidColor(state); - expect(diagnostics.length).toBe(1); - expect(diagnostics[0]!.severity).toBe('error'); - expect(diagnostics[0]!.message).toMatch(/red/); - expect(diagnostics[0]!.message).toMatch(/hex/); - }); - - it('returns empty for valid hex colors', () => { - const state = buildState({ colors: { primary: '#647D66' } }); - expect(invalidColor(state)).toEqual([]); - }); - - it('skips token references (handled by broken-ref rule)', () => { - const state = buildState({ colors: { alias: '{colors.primary}' as string } }); - expect(invalidColor(state)).toEqual([]); - }); -}); diff --git a/packages/linter/src/linter/rules/invalid-color.ts b/packages/linter/src/linter/rules/invalid-color.ts deleted file mode 100644 index 01aecd83..00000000 --- a/packages/linter/src/linter/rules/invalid-color.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { DesignSystemState } from '../../model/spec.js'; -import type { Diagnostic } from '../spec.js'; -import { isValidColor, isTokenReference } from '../../model/spec.js'; - -/** - * Invalid color format — colors must be valid hex (#RGB or #RRGGBB). - */ -export function invalidColor(state: DesignSystemState): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - for (const [key, value] of state.symbolTable) { - if (key.startsWith('colors.') && typeof value === 'string') { - if (isTokenReference(value)) continue; - if (!isValidColor(value)) { - diagnostics.push({ - severity: 'error', - path: key, - message: `'${value}' is not a valid hex color. Expected #RGB or #RRGGBB.`, - }); - } - } - } - return diagnostics; -} diff --git a/packages/linter/src/linter/rules/non-standard-unit.test.ts b/packages/linter/src/linter/rules/non-standard-unit.test.ts deleted file mode 100644 index 4422610d..00000000 --- a/packages/linter/src/linter/rules/non-standard-unit.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { nonStandardUnit } from './non-standard-unit.js'; -import { buildState } from './test-helpers.js'; - -describe('nonStandardUnit', () => { - it('emits warning when typography uses em unit', () => { - const state = buildState({ - colors: { primary: '#000000' }, - typography: { - headline: { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02em' }, - }, - }); - const diagnostics = nonStandardUnit(state); - expect(diagnostics.length).toBeGreaterThan(0); - expect(diagnostics[0]!.message).toMatch(/non-standard/); - expect(diagnostics[0]!.message).toMatch(/em/); - }); - - it('returns empty for standard px/rem units', () => { - const state = buildState({ - colors: { primary: '#000000' }, - typography: { - headline: { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '1.2px' }, - }, - }); - const nonStd = nonStandardUnit(state).filter(d => d.message.includes('non-standard')); - expect(nonStd.length).toBe(0); - }); -}); diff --git a/packages/linter/src/linter/rules/non-standard-unit.ts b/packages/linter/src/linter/rules/non-standard-unit.ts deleted file mode 100644 index 5334bd47..00000000 --- a/packages/linter/src/linter/rules/non-standard-unit.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { DesignSystemState, ResolvedDimension } from '../../model/spec.js'; -import type { Diagnostic } from '../spec.js'; -import { isStandardDimension } from '../../model/spec.js'; - -const BASE_FONT_SIZE = 16; - -/** - * Non-standard dimension unit — warns when dimensions use units - * other than px or rem (the spec-standard units). - */ -export function nonStandardUnit(state: DesignSystemState): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - - const checkDimension = (dim: ResolvedDimension, path: string) => { - const raw = `${dim.value}${dim.unit}`; - if (!isStandardDimension(raw)) { - let suggestion = `consider converting to px or rem`; - if (dim.unit === 'em' || dim.unit === 'rem') { - const pxValue = dim.value * BASE_FONT_SIZE; - suggestion = `consider converting: ${raw} ≈ ${pxValue}px (at ${BASE_FONT_SIZE}px base)`; - } - diagnostics.push({ - severity: 'warning', - path, - message: `'${raw}' uses non-standard unit '${dim.unit}'. Spec recommends px or rem — ${suggestion}.`, - }); - } - }; - - // Check typography dimension properties - for (const [name, typo] of state.typography) { - const dimProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const; - for (const prop of dimProps) { - const dim = typo[prop]; - if (dim) checkDimension(dim, `typography.${name}.${prop}`); - } - } - - // Check rounded - for (const [name, dim] of state.rounded) { - checkDimension(dim, `rounded.${name}`); - } - - // Check spacing - for (const [name, dim] of state.spacing) { - checkDimension(dim, `spacing.${name}`); - } - - return diagnostics; -} diff --git a/packages/linter/src/linter/rules/test-helpers.ts b/packages/linter/src/linter/rules/test-helpers.ts index 37abe1bd..5cf0cc10 100644 --- a/packages/linter/src/linter/rules/test-helpers.ts +++ b/packages/linter/src/linter/rules/test-helpers.ts @@ -11,6 +11,9 @@ const modelHandler = new ModelHandler(); export function buildState(overrides: Partial = {}): DesignSystemState { const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; const result = modelHandler.execute(parsed); - if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); - return result.data; + const hasErrors = result.diagnostics.some(d => d.severity === 'error'); + if (hasErrors) { + throw new Error(`Model build failed: ${result.diagnostics.map(d => d.message).join(', ')}`); + } + return result.designSystem; } diff --git a/packages/linter/src/linter/runner.test.ts b/packages/linter/src/linter/runner.test.ts index 76fa9af9..816e02e2 100644 --- a/packages/linter/src/linter/runner.test.ts +++ b/packages/linter/src/linter/runner.test.ts @@ -12,8 +12,11 @@ const modelHandler = new ModelHandler(); function buildState(overrides: Partial = {}): DesignSystemState { const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; const result = modelHandler.execute(parsed); - if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); - return result.data; + const hasErrors = result.diagnostics.some(d => d.severity === 'error'); + if (hasErrors) { + throw new Error(`Model build failed: ${result.diagnostics.map(d => d.message).join(', ')}`); + } + return result.designSystem; } describe('runLinter', () => { @@ -47,16 +50,24 @@ describe('runLinter', () => { describe('preEvaluate', () => { it('groups diagnostics into fixes, improvements, and suggestions', () => { const state = buildState({ - colors: { accent: 'red', secondary: '#ffff00', white: '#ffffff' }, + colors: { + primary: '#647D66', + secondary: '#ffff00', + white: '#ffffff', + }, components: { 'button-bad': { backgroundColor: '{colors.secondary}', textColor: '{colors.white}', }, + 'button-broken': { + backgroundColor: '{colors.nonexistent}', + textColor: '{colors.white}', + } }, }); const graded = preEvaluate(state); - expect(graded.fixes.length).toBeGreaterThan(0); // error: invalid color + expect(graded.fixes.length).toBeGreaterThan(0); // error: broken ref expect(graded.improvements.length).toBeGreaterThan(0); // warning: contrast expect(graded.suggestions.length).toBeGreaterThan(0); // info: summary }); diff --git a/packages/linter/src/linter/spec.ts b/packages/linter/src/linter/spec.ts index a6a6540b..43a5ab1a 100644 --- a/packages/linter/src/linter/spec.ts +++ b/packages/linter/src/linter/spec.ts @@ -1,17 +1,7 @@ -import { z } from 'zod'; -import type { DesignSystemState, ResolvedValue } from '../model/spec.js'; +import type { DesignSystemState, Diagnostic } from '../model/spec.js'; +export type { Diagnostic, Severity } from '../model/spec.js'; -// ── SEVERITY ─────────────────────────────────────────────────────── -export const SeveritySchema = z.enum(['error', 'warning', 'info']); -export type Severity = z.infer; -// ── DIAGNOSTIC ───────────────────────────────────────────────────── -export interface Diagnostic { - severity: Severity; - /** Token path, e.g. "colors.primary", "components.button-primary.textColor" */ - path?: string; - message: string; -} // ── LINT RESULT ──────────────────────────────────────────────────── export interface LintResult { diff --git a/packages/linter/src/model/handler.test.ts b/packages/linter/src/model/handler.test.ts index 6b41eb35..c988bbd2 100644 --- a/packages/linter/src/model/handler.test.ts +++ b/packages/linter/src/model/handler.test.ts @@ -18,28 +18,30 @@ describe('ModelHandler', () => { const result = handler.execute(makeParsed({ colors: { primary: '#647D66', secondary: '#ff0000' }, })); - expect(result.success).toBe(true); - if (result.success) { - const primary = result.data.symbolTable.get('colors.primary'); - expect(primary).toBeDefined(); - expect(typeof primary === 'object' && primary !== null && 'type' in primary && primary.type === 'color').toBe(true); - if (typeof primary === 'object' && primary !== null && 'hex' in primary) { - expect(primary.hex).toBe('#647d66'); - } - - expect(result.data.colors.size).toBe(2); + const primary = result.designSystem.symbolTable.get('colors.primary'); + expect(primary).toBeDefined(); + expect(typeof primary === 'object' && primary !== null && 'type' in primary && primary.type === 'color').toBe(true); + if (typeof primary === 'object' && primary !== null && 'hex' in primary) { + expect(primary.hex).toBe('#647d66'); } + + expect(result.designSystem.colors.size).toBe(2); + }); + it('emits diagnostic for invalid color format', () => { + const result = handler.execute(makeParsed({ + colors: { primary: 'invalid-color' }, + })); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.path).toBe('colors.primary'); + expect(result.diagnostics[0]!.severity).toBe('error'); }); it('normalizes #RGB shorthand to #RRGGBB', () => { const result = handler.execute(makeParsed({ colors: { accent: '#abc' }, })); - expect(result.success).toBe(true); - if (result.success) { - const accent = result.data.colors.get('accent'); - expect(accent?.hex).toBe('#aabbcc'); - } + const accent = result.designSystem.colors.get('accent'); + expect(accent?.hex).toBe('#aabbcc'); }); }); @@ -54,13 +56,10 @@ describe('ModelHandler', () => { }, }, })); - expect(result.success).toBe(true); - if (result.success) { - const btn = result.data.components.get('button-primary'); - expect(btn).toBeDefined(); - const bg = btn?.properties.get('backgroundColor'); - expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); - } + const btn = result.designSystem.components.get('button-primary'); + expect(btn).toBeDefined(); + const bg = btn?.properties.get('backgroundColor'); + expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); }); }); @@ -78,14 +77,11 @@ describe('ModelHandler', () => { }, }, })); - expect(result.success).toBe(true); - if (result.success) { - const btn = result.data.components.get('button'); - const bg = btn?.properties.get('backgroundColor'); - expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); - if (typeof bg === 'object' && bg !== null && 'hex' in bg) { - expect(bg.hex).toBe('#647d66'); - } + const btn = result.designSystem.components.get('button'); + const bg = btn?.properties.get('backgroundColor'); + expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true); + if (typeof bg === 'object' && bg !== null && 'hex' in bg) { + expect(bg.hex).toBe('#647d66'); } }); }); @@ -104,59 +100,90 @@ describe('ModelHandler', () => { }, }, })); - expect(result.success).toBe(true); - if (result.success) { - const card = result.data.components.get('card'); - expect(card?.unresolvedRefs.length).toBeGreaterThan(0); - } + const card = result.designSystem.components.get('card'); + expect(card?.unresolvedRefs.length).toBeGreaterThan(0); }); }); // ── Cycle N: Non-standard units are parsed, not dropped ──────────── describe('non-standard dimension units', () => { - it('preserves em units in typography letterSpacing', () => { + it('emits diagnostic for non-standard dimension units in typography', () => { const result = handler.execute(makeParsed({ typography: { 'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02em' }, }, })); - expect(result.success).toBe(true); - if (result.success) { - const headline = result.data.typography.get('headline'); - expect(headline?.letterSpacing).toBeDefined(); - expect(headline?.letterSpacing?.value).toBe(-0.02); - expect(headline?.letterSpacing?.unit).toBe('em'); - } + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.path).toBe('typography.headline.letterSpacing'); + expect(result.diagnostics[0]!.severity).toBe('error'); + }); + }); + describe('typography validation', () => { + it('emits diagnostic when fontFamily is a hex color', () => { + const result = handler.execute(makeParsed({ + typography: { + 'headline': { fontFamily: '#ffffff' }, + }, + })); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.path).toBe('typography.headline.fontFamily'); + expect(result.diagnostics[0]!.severity).toBe('error'); + }); + + it('emits diagnostic when fontWeight is not a number', () => { + const result = handler.execute(makeParsed({ + typography: { + 'headline': { fontWeight: 'bold' }, + }, + })); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.path).toBe('typography.headline.fontWeight'); + expect(result.diagnostics[0]!.severity).toBe('error'); + }); + }); + + describe('rounded validation', () => { + it('emits diagnostic for non-standard units in rounded', () => { + const result = handler.execute(makeParsed({ + rounded: { sm: '2em' }, + })); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.path).toBe('rounded.sm'); + expect(result.diagnostics[0]!.severity).toBe('error'); }); }); // ── Cycle 13: Compute WCAG contrast ratio ───────────────────────── + describe('WCAG contrast ratio', () => { it('computes correct contrast ratio for black on white (21:1)', () => { const result = handler.execute(makeParsed({ colors: { black: '#000000', white: '#ffffff' }, })); - expect(result.success).toBe(true); - if (result.success) { - const black = result.data.colors.get('black'); - const white = result.data.colors.get('white'); - expect(black).toBeDefined(); - expect(white).toBeDefined(); - - const ratio = contrastRatio(black!, white!); - expect(ratio).toBeCloseTo(21, 0); - } + const black = result.designSystem.colors.get('black'); + const white = result.designSystem.colors.get('white'); + expect(black).toBeDefined(); + expect(white).toBeDefined(); + + const ratio = contrastRatio(black!, white!); + expect(ratio).toBeCloseTo(21, 0); }); it('computes correct contrast for identical colors (1:1)', () => { const result = handler.execute(makeParsed({ colors: { red1: '#ff0000', red2: '#ff0000' }, })); - expect(result.success).toBe(true); - if (result.success) { - const ratio = contrastRatio(result.data.colors.get('red1')!, result.data.colors.get('red2')!); - expect(ratio).toBeCloseTo(1, 1); - } + const ratio = contrastRatio(result.designSystem.colors.get('red1')!, result.designSystem.colors.get('red2')!); + expect(ratio).toBeCloseTo(1, 1); + }); + }); + + describe('return signature', () => { + it('returns diagnostics array', () => { + const result = handler.execute(makeParsed({ + colors: { primary: '#647D66' }, + })); + expect(result.diagnostics).toBeDefined(); }); }); }); diff --git a/packages/linter/src/model/handler.ts b/packages/linter/src/model/handler.ts index 21224e99..5c24599f 100644 --- a/packages/linter/src/model/handler.ts +++ b/packages/linter/src/model/handler.ts @@ -2,13 +2,14 @@ import type { ParsedDesignSystem } from '../parser/spec.js'; import type { ModelSpec, ModelResult, - DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography, ResolvedValue, ComponentDef, + Diagnostic, } from './spec.js'; + import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts } from './spec.js'; const MAX_REFERENCE_DEPTH = 10; @@ -22,6 +23,7 @@ const MAX_REFERENCE_DEPTH = 10; export class ModelHandler implements ModelSpec { execute(input: ParsedDesignSystem): ModelResult { try { + const diagnostics: Diagnostic[] = []; const symbolTable = new Map(); const colors = new Map(); const typography = new Map(); @@ -40,7 +42,12 @@ export class ModelHandler implements ModelSpec { colors.set(name, resolved); symbolTable.set(`colors.${name}`, resolved); } else { - // Store as-is for linter to catch + diagnostics.push({ + severity: 'error', + path: `colors.${name}`, + message: `'${raw}' is not a valid color. Expected a hex color code (e.g., #ffffff).`, + }); + // Store as-is for fallback symbolTable.set(`colors.${name}`, raw); } } @@ -49,7 +56,7 @@ export class ModelHandler implements ModelSpec { // Typography if (input.typography) { for (const [name, props] of Object.entries(input.typography)) { - const resolved = parseTypography(props); + const resolved = parseTypography(props, `typography.${name}`, diagnostics); typography.set(name, resolved); symbolTable.set(`typography.${name}`, resolved); } @@ -58,12 +65,28 @@ export class ModelHandler implements ModelSpec { // Rounded if (input.rounded) { for (const [name, raw] of Object.entries(input.rounded)) { - if (isParseableDimension(raw)) { - const resolved = parseDimension(raw); - rounded.set(name, resolved); - symbolTable.set(`rounded.${name}`, resolved); - } else { - symbolTable.set(`rounded.${name}`, raw); + if (typeof raw === 'string') { + if (isParseableDimension(raw)) { + const resolved = parseDimension(raw); + if (resolved.unit !== 'px' && resolved.unit !== 'rem') { + diagnostics.push({ + severity: 'error', + path: `rounded.${name}`, + message: `'${raw}' has an invalid unit '${resolved.unit}'. Only px and rem are allowed.`, + }); + } + rounded.set(name, resolved); + symbolTable.set(`rounded.${name}`, resolved); + } else if (!isTokenReference(raw)) { + diagnostics.push({ + severity: 'error', + path: `rounded.${name}`, + message: `'${raw}' is not a valid dimension.`, + }); + symbolTable.set(`rounded.${name}`, raw); + } else { + symbolTable.set(`rounded.${name}`, raw); + } } } } @@ -126,8 +149,7 @@ export class ModelHandler implements ModelSpec { } return { - success: true, - data: { + designSystem: { name: input.name, description: input.description, colors, @@ -137,15 +159,25 @@ export class ModelHandler implements ModelSpec { components, symbolTable, }, + diagnostics, }; } catch (error) { return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: error instanceof Error ? error.message : String(error), - recoverable: false, + designSystem: { + colors: new Map(), + typography: new Map(), + rounded: new Map(), + spacing: new Map(), + components: new Map(), + symbolTable: new Map(), }, + diagnostics: [ + { + severity: 'error', + message: `Unexpected error during model building: ${error instanceof Error ? error.message : String(error) + }`, + }, + ], }; } } @@ -205,19 +237,56 @@ function parseDimension(raw: string): ResolvedDimension { /** * Parse a typography properties object into a ResolvedTypography. */ -function parseTypography(props: Record): ResolvedTypography { +function parseTypography(props: Record, path: string, diagnostics: Diagnostic[]): ResolvedTypography { const result: ResolvedTypography = { type: 'typography' }; - if (typeof props['fontFamily'] === 'string') result.fontFamily = props['fontFamily']; - if (typeof props['fontWeight'] === 'number') result.fontWeight = props['fontWeight']; + if (typeof props['fontFamily'] === 'string') { + const ff = props['fontFamily']; + if (isValidColor(ff)) { + diagnostics.push({ + severity: 'error', + path: `${path}.fontFamily`, + message: `'${ff}' appears to be a color, not a valid font family.`, + }); + } + result.fontFamily = ff; + } + if (props['fontWeight'] !== undefined) { + const fw = props['fontWeight']; + if (typeof fw !== 'number') { + diagnostics.push({ + severity: 'error', + path: `${path}.fontWeight`, + message: `'${fw}' is not a valid font weight. Expected a number.`, + }); + } else { + result.fontWeight = fw; + } + } if (typeof props['fontFeature'] === 'string') result.fontFeature = props['fontFeature']; if (typeof props['fontVariation'] === 'string') result.fontVariation = props['fontVariation']; const dimensionProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const; for (const prop of dimensionProps) { const raw = props[prop]; - if (typeof raw === 'string' && isParseableDimension(raw)) { - result[prop] = parseDimension(raw); + if (typeof raw === 'string') { + if (isParseableDimension(raw)) { + const parsed = parseDimension(raw); + if (parsed.unit !== 'px' && parsed.unit !== 'rem') { + diagnostics.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${raw}' has an invalid unit '${parsed.unit}'. Only px and rem are allowed.`, + }); + } + result[prop] = parsed; + } else if (!isTokenReference(raw)) { + diagnostics.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${raw}' is not a valid dimension.`, + }); + } } } diff --git a/packages/linter/src/model/spec.ts b/packages/linter/src/model/spec.ts index ac11e9be..4f954ebb 100644 --- a/packages/linter/src/model/spec.ts +++ b/packages/linter/src/model/spec.ts @@ -1,6 +1,15 @@ import { z } from 'zod'; import type { ParsedDesignSystem } from '../parser/spec.js'; +export const SeveritySchema = z.enum(['error', 'warning', 'info']); +export type Severity = z.infer; + +export interface Diagnostic { + severity: Severity; + path?: string; + message: string; +} + // ── RESOLVED VALUE TYPES ─────────────────────────────────────────── export interface ResolvedColor { type: 'color'; @@ -86,16 +95,10 @@ export const ModelErrorCode = z.enum([ ]); // ── RESULT ───────────────────────────────────────────────────────── -export type ModelResult = - | { success: true; data: DesignSystemState } - | { - success: false; - error: { - code: z.infer; - message: string; - recoverable: boolean; - }; - }; +export interface ModelResult { + designSystem: DesignSystemState; + diagnostics: Diagnostic[]; +} // ── INTERFACE ────────────────────────────────────────────────────── export interface ModelSpec { diff --git a/packages/linter/src/tailwind/handler.test.ts b/packages/linter/src/tailwind/handler.test.ts index e8d2dac5..b5f95cf5 100644 --- a/packages/linter/src/tailwind/handler.test.ts +++ b/packages/linter/src/tailwind/handler.test.ts @@ -9,8 +9,11 @@ const modelHandler = new ModelHandler(); function buildState(overrides: Partial = {}) { const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; const result = modelHandler.execute(parsed); - if (!result.success) throw new Error(`Model build failed: ${result.error.message}`); - return result.data; + const hasErrors = result.diagnostics.some(d => d.severity === 'error'); + if (hasErrors) { + throw new Error(`Model build failed: ${result.diagnostics.map(d => d.message).join(', ')}`); + } + return result.designSystem; } describe('TailwindEmitterHandler', () => { From f5a742c1ab14cf8f327b81a9fe71606bde723ca1 Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 10 Apr 2026 19:36:53 -0400 Subject: [PATCH 4/6] add .agents --- .agents/tdd/SKILL.md | 81 ++++++++++ .agents/typed-service-contracts/SKILL.md | 190 +++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .agents/tdd/SKILL.md create mode 100644 .agents/typed-service-contracts/SKILL.md diff --git a/.agents/tdd/SKILL.md b/.agents/tdd/SKILL.md new file mode 100644 index 00000000..2db46810 --- /dev/null +++ b/.agents/tdd/SKILL.md @@ -0,0 +1,81 @@ +--- +name: tdd-red-green-refactor +description: > + Enforces a disciplined Red-Green-Refactor (TDD) workflow in TypeScript/Node.js. + Use this whenever creating new features, fixing bugs, or migrating logic to ensure + high-quality, verifiable implementations. +--- + +# Red-Green-Refactor (TDD) Skill: TypeScript Edition + +This skill implements a structural framework for AI-assisted programming to ensure every line of code is verifiable, typed, and purposeful. + +## The Three-Phase Cycle + +### Phase 1: Red (Establish Failure) +You must prove the feature does not exist and that your test is valid. +1. **Write One Test**: Create a single test case (e.g., in Vitest or Jest) for the next small piece of behavior. +2. **Execute & Fail**: Run the test. It must fail. +3. **Verify**: Ensure the failure is related to the missing logic (e.g., `ReferenceError: add is not defined`) and not a configuration error. + +### Phase 2: Green (Minimal Pass) +Make the test pass as quickly and simply as possible. +1. **Minimal Implementation**: Write the simplest code that satisfies the test. Do not build for the future; focus strictly on the current "Red" test. +2. **Run Tests**: Execute the suite. All tests must be Green. +3. **Evidence**: The transition from Red to Green is the "Proof of Work" for the developer. + +### Phase 3: Refactor (Clean Up) +Improve the code structure while maintaining the "Green" state. +1. **Clean Up**: Improve naming, remove duplication, and optimize the code written in Phase 2. +2. **Safety Net**: Rerun the tests after every change. If they turn Red, revert the change immediately. + +--- + +## Core Operational Rules + +### 1. No "Horizontal Splurging" +You are strictly forbidden from writing a large "splurge" of multiple tests at once. You must follow a strictly incremental loop: +- **Write 1 Test -> See it Fail -> Write 1 Fix -> See it Pass**. +- Repeat this loop for every sub-feature. + +### 2. Impose Backpressure +Use automated assertions and strong typing (TypeScript) as backpressure to prevent the AI from "guessing" the solution or "playing in the mud" with low-quality code. + +### 3. Verification of Integrity +Never modify an existing test to make a failing implementation pass. If a test must change, it must be because the requirement changed, not because the code is difficult to write. + +--- + +## Example Workflow (TypeScript + Vitest) + +**Step 1: Red** +```typescript +// math.test.ts +import { describe, it, expect } from 'vitest'; +import { add } from './math'; + +describe('add', () => { + it('should sum two numbers', () => { + expect(add(2, 2)).toBe(4); // Fails: ReferenceError: add is not defined + }); +}); +``` + +**Step 2: Green** +```typescript +// math.ts +export const add = (a: any, b: any) => { + return 4; // Passes: Minimal code to satisfy the test +}; +``` + +**Step 3: Refactor** +```typescript +// math.ts +/** + * Sums two numbers with explicit type safety. + */ +export const add = (a: number, b: number): number => { + return a + b; // Passes: Proper implementation with safety net +}; +``` diff --git a/.agents/typed-service-contracts/SKILL.md b/.agents/typed-service-contracts/SKILL.md new file mode 100644 index 00000000..38f13062 --- /dev/null +++ b/.agents/typed-service-contracts/SKILL.md @@ -0,0 +1,190 @@ +--- +name: typed-service-contracts +description: Architecture standard for building robust, type-safe TypeScript services using the "Spec and Handler" pattern. Use when building CLIs, libraries, or complex business logic. +--- + +# Typed Service Contracts (Spec & Handler Pattern) + +This skill defines a **Vertical Slice Architecture** backed by **Design by Contract (DbC)** principles. It treats application logic as rigorously defined Units of Work where inputs are parsed (not just validated) and errors are treated as values (Result Pattern) rather than exceptions. + +## When to use this skill + +- **Building CLIs or Libraries:** When you need strict boundaries between user input and system logic. +- **Complex Validation:** When inputs require transformation (parsing) before being useful (e.g., ensuring a string is a valid file path). +- **High-Reliability Requirements:** When you cannot afford unhandled runtime exceptions and need exhaustive error handling. +- **Testing Focus:** When you want to separate data validation tests from business logic tests. + +## Architecture Components + +### 1. The Spec (`spec.ts`) +The "Contract" or "Port". It defines the *What*. It must contain: +- **Input Schema:** A Zod schema that parses raw input into a valid DTO. +- **Output Schema:** A Zod schema defining the successful data structure. +- **Error Schema:** A discriminated union of specific failure modes (not generic errors). +- **Result Type:** A `DiscriminatedUnion` of `Success | Failure`. +- **Interface:** The capability definition (e.g., `interface ConfigureSpec`). + +### 2. The Handler (`handler.ts`) +The "Implementation" or "Adapter". It defines the *How*. It must: +- Implement the Interface defined in the Spec. +- Be an "Impure" class that handles side effects (File System, API calls). +- **NEVER throw** exceptions. It must catch internal errors and map them to the `Result` type. + +--- + +## How to use it + +### Step 1: Define the Contract (`spec.ts`) + +Follow this template to define the boundaries. + +```typescript +import { z } from 'zod'; + +// 1. VALIDATION HELPERS (Reusable Refinements) +export const SafePathSchema = z.string() + .min(1) + .refine(p => !p.includes('..'), "No traversal allowed"); + +// 2. INPUT (The Command) - "Parse, don't validate" +export const MyTaskInputSchema = z.object({ + path: SafePathSchema, + force: z.boolean().default(false), +}); +export type MyTaskInput = z.infer; + +// 3. ERROR CODES (Exhaustive) +export const MyTaskErrorCode = z.enum([ + 'FILE_NOT_FOUND', + 'PERMISSION_DENIED', + 'UNKNOWN_ERROR' +]); + +// 4. RESULT (The Monad) +export const MyTaskSuccess = z.object({ + success: z.literal(true), + data: z.string(), // The output payload +}); + +export const MyTaskFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: MyTaskErrorCode, + message: z.string(), + suggestion: z.string().optional(), + recoverable: z.boolean(), + }) +}); + +export type MyTaskResult = + | z.infer + | z.infer; + +// 5. INTERFACE (The Capability) +export interface MyTaskSpec { + execute(input: MyTaskInput): Promise; +} + +``` + +### Step 2: Implement the Handler (`handler.ts`) + +Follow this template to implement the logic. + +```typescript +import { MyTaskSpec, MyTaskInput, MyTaskResult } from './spec.js'; +import * as fs from 'fs'; + +export class MyTaskHandler implements MyTaskSpec { + async execute(input: MyTaskInput): Promise { + try { + // 1. Business Logic + if (!fs.existsSync(input.path)) { + // 2. Explicit Error Return (No Throwing) + return { + success: false, + error: { + code: 'FILE_NOT_FOUND', + message: `Path does not exist: ${input.path}`, + recoverable: true + } + }; + } + + // 3. Success Return + return { + success: true, + data: 'Operation complete' + }; + + } catch (error) { + // 4. Safety Net: Catch unknown runtime errors + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false + } + }; + } + } +} + +``` + +### Step 3: Testing Strategy + +Do not write monolithic tests. Split them into **Contract Tests** and **Logic Tests**. + +#### A. Contract Tests (Schema) + +Test the *Bouncer*. Ensure invalid data is rejected before it reaches the handler. + +* **Focus:** Edge cases, validation rules, Zod refinements. +* **Style:** Data-driven (Table tests). + +```typescript +// spec.test.ts +import { MyTaskInputSchema } from './spec'; + +const invalidCases = [ + { val: '../etc/passwd', err: 'No traversal allowed' }, + { val: '', err: 'min(1)' }, +]; + +test.each(invalidCases)('validates paths', ({ val, err }) => { + const result = MyTaskInputSchema.safeParse({ path: val }); + expect(result.success).toBe(false); +}); + +``` + +#### B. Logic Tests (Handler) + +Test the *Chef*. Mock external dependencies (fs, network) and assert the Result Object. + +* **Focus:** Business logic flow, error mapping, success states. +* **Style:** Mocked unit tests or Scenario Runners. + +```typescript +// handler.test.ts +import { MyTaskHandler } from './handler'; +import { vi } from 'vitest'; // or jest + +test('returns FILE_NOT_FOUND if path missing', async () => { + // MOCK + vi.mocked(fs.existsSync).mockReturnValue(false); + + // EXECUTE + const handler = new MyTaskHandler(); + const result = await handler.execute({ path: '/fake' }); + + // ASSERT (Check the Result Object) + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('FILE_NOT_FOUND'); + } +}); + +``` \ No newline at end of file From c6a433f3027d6815086d4e47ff7fbdd1baefac4a Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 10 Apr 2026 19:41:12 -0400 Subject: [PATCH 5/6] ci: add GitHub Action workflow for tests --- .github/workflows/test.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..6e6a09d2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Test + +on: + push: + branches: [ main, feat/linter ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun test From 153475cbbe781273719f24e1610d181269a2de29 Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 10 Apr 2026 19:43:32 -0400 Subject: [PATCH 6/6] workflow --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6e6a09d2..806e52b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,8 @@ name: Test on: push: - branches: [ main, feat/linter ] + branches: ['**'] pull_request: - branches: [ main ] jobs: test: