From bda5c0e3ed4887f789344aa66d3c7d09fdf2de32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 9 May 2026 15:24:22 +0000 Subject: [PATCH] feat(plugins): token-map atom impl (Phase 6/7 entry slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan P1 / spec §10 / §21.3.1. apps/daemon/src/plugins/atoms/token-map.ts ships the daemon-side crosswalk behind the SKILL.md fragment landed in §3.M3. Given a project cwd that already has a token bag (`code/tokens.json` from design-extract OR `figma/tokens.json` from figma-extract) plus the active design system's token vocabulary, the runner produces: /token-map/colors.json /token-map/typography.json /token-map/spacing.json /token-map/radius.json /token-map/shadow.json /token-map/unmatched.json [{ source, sourceName?, kind, reason, hint? }] /token-map/meta.json { sourceKind, generatedAt, atomDigest, designSystemId?, targetTokenCount, sourceTokenCount, matchedTokenCount } Match strategies (deterministic, first-win): 1. Exact value match (#5b8def === #5b8def). 2. Normalised hex (#abc → #aabbcc, ignores case). 3. Fuzzy name (--color-primary → --ds-color-primary; strips leading -- / ds- / odds- / theme- prefixes and lower-cases). Anything else lands unmatched[] with one of: - 'no-target-equivalent' no target with same value/name - 'target-collision' multiple sources mapped to one target (first wins; subsequent listed unmatched with hint pointing at the first claimant) - 'invalid-source' malformed source value Strict mode aborts on any unmatched; soft mode (default) populates unmatched.json + continues so the human can audit. Helper: parseDesignSystemTokens(body) → DesignSystemToken[] Heuristic DESIGN.md token extraction. Lifts CSS custom properties (`--ds-color-fg: #111`) and markdown table rows that look like token declarations. Daemon callers may pass a hand-curated bag instead of relying on this parser. Existing patch-edit typecheck noise fixed in passing: - PatchStepRecord now extends Omit so optional override is sound under exactOptionalPropertyTypes. - rationale assignment guarded against undefined. Daemon tests: 1588 → 1600 (+12 cases on plugins-token-map: exact match, normalised hex, fuzzy name, no-target-equivalent, target- collision, strict mode abort, multi-kind crosswalk, code+figma disk inputs round-trip + bucket persistence + missing-input error, DESIGN.md CSS custom property + markdown table parsers). Co-authored-by: Tom Huang <1043269994@qq.com> --- apps/daemon/src/plugins/atoms/patch-edit.ts | 6 +- apps/daemon/src/plugins/atoms/token-map.ts | 412 ++++++++++++++++++++ apps/daemon/src/plugins/index.ts | 1 + apps/daemon/tests/plugins-token-map.test.ts | 194 +++++++++ 4 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 apps/daemon/src/plugins/atoms/token-map.ts create mode 100644 apps/daemon/tests/plugins-token-map.test.ts diff --git a/apps/daemon/src/plugins/atoms/patch-edit.ts b/apps/daemon/src/plugins/atoms/patch-edit.ts index 8cea6657c..d42311ed9 100644 --- a/apps/daemon/src/plugins/atoms/patch-edit.ts +++ b/apps/daemon/src/plugins/atoms/patch-edit.ts @@ -46,10 +46,10 @@ import type { OwnershipEntry, RewriteStep } from './rewrite-plan.js'; export type PatchStepStatus = 'pending' | 'completed' | 'skipped' | 'failed'; -export interface PatchStepRecord extends RewriteStep { +export interface PatchStepRecord extends Omit { + rationale?: string; status?: PatchStepStatus; completedAt?: string; - rationale?: string; } export interface PatchReceiptEntry { @@ -160,7 +160,7 @@ export async function applyPatchForStep(input: ApplyPatchInput): Promise/token-map/colors.json +// /token-map/typography.json +// /token-map/spacing.json +// /token-map/radius.json +// /token-map/shadow.json +// /token-map/unmatched.json — { source, reason }[] +// /token-map/meta.json — { sourceKind, generatedAt, +// atomDigest, designSystemId? } +// +// Match strategy (deterministic, in this order): +// 1. Exact value match (#abc === #abc). +// 2. Normalised hex (#abc → #aabbcc, ignore case). +// 3. Named source token AND a target with a matching name (e.g. +// --primary-500 → ds-primary-500). Fuzzy match strips '--' / +// 'ds-' prefixes and lower-cases. +// +// Anything else lands in unmatched[] with one of the reasons: +// 'no-target-equivalent' — no target with the same value/name. +// 'target-collision' — multiple sources map to the same target +// (kept for the first source; subsequent +// are listed unmatched with a hint). +// 'invalid-source' — source token value is malformed. +// +// The atom is intentionally conservative: it never invents targets, +// never relies on perceptual proximity (the SKILL.md fragment routes +// that to the visual-diff evaluator). False negatives are preferable +// to false positives — `unmatched.json` is the audit list the user +// reviews. + +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { createHash } from 'node:crypto'; +import type { DesignExtractReport, DesignTokenEntry, DesignTokenKind } from './design-extract.js'; + +export interface DesignSystemToken { + // Canonical token name (e.g. 'ds-primary-500', '--ds-color-fg'). + name: string; + // Token kind. Loose because design systems vary (e.g. some collapse + // 'spacing' + 'radius' into one scale); we mirror the same five + // kinds design-extract emits. + kind: DesignTokenKind; + value: string; + // Optional human description — surfaced in unmatched.json hints. + description?: string; +} + +export interface DesignSystemTokenBag { + // Daemon-side caller fills this from the active design system's + // DESIGN.md / tokens.json. + id?: string; + tokens: DesignSystemToken[]; +} + +export interface TokenMapMatch { + source: string; // raw input value (or token name when present) + sourceName?: string; + target: string; // matched target token name + targetValue: string; + via: 'exact' | 'normalised-hex' | 'name'; + kind: DesignTokenKind; + // The source token's audit trail (file:line entries) so a reviewer + // can audit "which target was chosen for which call site". + sources: string[]; +} + +export interface TokenMapUnmatched { + source: string; + sourceName?: string; + kind: DesignTokenKind; + reason: 'no-target-equivalent' | 'target-collision' | 'invalid-source'; + hint?: string; +} + +export interface TokenMapReport { + colors: TokenMapMatch[]; + typography: TokenMapMatch[]; + spacing: TokenMapMatch[]; + radius: TokenMapMatch[]; + shadow: TokenMapMatch[]; + unmatched: TokenMapUnmatched[]; + meta: { + sourceKind: 'figma' | 'code'; + generatedAt: string; + atomDigest: string; + designSystemId?: string; + targetTokenCount: number; + sourceTokenCount: number; + matchedTokenCount: number; + }; +} + +export interface TokenMapOptions { + cwd: string; + // Source bag. When omitted, the runner reads /code/tokens.json + // (preferred for code-migration) or falls back to + // /figma/tokens.json (figma-migration). + source?: { kind: 'figma' | 'code'; report: DesignExtractReport }; + // The active design system's tokens. Caller supplies this; the atom + // never reads filesystem directly so it stays unit-testable. + designSystem: DesignSystemTokenBag; + // Strict mode aborts when ANY source token can't be mapped. + // Default 'soft' — populates unmatched[] and continues. + strict?: boolean; +} + +const HEX_RE = /^#([0-9a-fA-F]{3,8})$/; + +export async function runTokenMap(opts: TokenMapOptions): Promise { + const cwd = path.resolve(opts.cwd); + + // Resolve source. + let sourceKind: 'figma' | 'code'; + let source: DesignExtractReport; + if (opts.source) { + sourceKind = opts.source.kind; + source = opts.source.report; + } else { + const codePath = path.join(cwd, 'code', 'tokens.json'); + const figmaPath = path.join(cwd, 'figma', 'tokens.json'); + if (await pathExists(codePath)) { + sourceKind = 'code'; + source = JSON.parse(await fsp.readFile(codePath, 'utf8')) as DesignExtractReport; + } else if (await pathExists(figmaPath)) { + sourceKind = 'figma'; + source = JSON.parse(await fsp.readFile(figmaPath, 'utf8')) as DesignExtractReport; + } else { + throw new Error(`token-map: missing both code/tokens.json and figma/tokens.json (run design-extract or figma-extract first)`); + } + } + + const targets = indexDesignSystem(opts.designSystem.tokens); + const unmatched: TokenMapUnmatched[] = []; + const claimed: Map = new Map(); + const buckets = { + colors: [] as TokenMapMatch[], + typography: [] as TokenMapMatch[], + spacing: [] as TokenMapMatch[], + radius: [] as TokenMapMatch[], + shadow: [] as TokenMapMatch[], + }; + + let sourceTokenCount = 0; + let matchedTokenCount = 0; + + for (const kind of ['colors', 'typography', 'spacing', 'radius', 'shadow'] as const) { + for (const entry of source[kind]) { + sourceTokenCount++; + const result = matchOne(kind, entry, targets); + if (!result.match) { + unmatched.push(result.unmatched); + continue; + } + const claimKey = result.match.target; + if (claimed.has(claimKey)) { + // Spec §21.3.1 target-collision: the second source claiming + // the same target lands unmatched with a hint pointing at + // the first claimant. + const first = claimed.get(claimKey)!; + unmatched.push({ + source: result.match.source, + ...(result.match.sourceName ? { sourceName: result.match.sourceName } : {}), + kind: result.match.kind, + reason: 'target-collision', + hint: `target ${claimKey} already mapped from ${first.source}`, + }); + continue; + } + claimed.set(claimKey, result.match); + buckets[kind as keyof typeof buckets].push(result.match); + matchedTokenCount++; + } + } + + if (opts.strict && unmatched.length > 0) { + throw new Error(`token-map (strict): ${unmatched.length} source tokens unmatched`); + } + + // Stable sort each bucket: by source value first, then by target. + for (const k of Object.keys(buckets) as (keyof typeof buckets)[]) { + buckets[k].sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)); + } + unmatched.sort((a, b) => a.kind.localeCompare(b.kind) || a.source.localeCompare(b.source)); + + const meta: TokenMapReport['meta'] = { + sourceKind, + generatedAt: new Date().toISOString(), + atomDigest: digestObject({ buckets, unmatched }), + targetTokenCount: opts.designSystem.tokens.length, + sourceTokenCount, + matchedTokenCount, + }; + if (opts.designSystem.id) meta.designSystemId = opts.designSystem.id; + + const report: TokenMapReport = { ...buckets, unmatched, meta }; + + await fsp.mkdir(path.join(cwd, 'token-map'), { recursive: true }); + for (const k of Object.keys(buckets) as (keyof typeof buckets)[]) { + await fsp.writeFile( + path.join(cwd, 'token-map', `${k}.json`), + JSON.stringify(buckets[k], null, 2) + '\n', + 'utf8', + ); + } + await fsp.writeFile(path.join(cwd, 'token-map', 'unmatched.json'), JSON.stringify(unmatched, null, 2) + '\n', 'utf8'); + await fsp.writeFile(path.join(cwd, 'token-map', 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8'); + + return report; +} + +// --- DESIGN.md token extraction (heuristic, used by daemon callers) --- + +// Parse a DESIGN.md body for token declarations. Lifts: +// - CSS custom property declarations (`--ds-color-fg: #111`) +// - Markdown table rows of shape `| name | value | …` +// The result is best-effort; daemon callers may pass a hand-curated +// token list instead. +export function parseDesignSystemTokens(body: string): DesignSystemToken[] { + const out: DesignSystemToken[] = []; + const seen = new Set(); + + // CSS custom properties. + const cssRe = /--([a-z][a-z0-9-]*)\s*:\s*([^;\n]+)/g; + let m: RegExpExecArray | null; + while ((m = cssRe.exec(body)) !== null) { + const name = `--${m[1]}`; + const value = (m[2] ?? '').trim(); + if (!value) continue; + const key = `${name}=${value}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ name, value, kind: classifyKind(name, value) }); + } + + // Markdown table rows. We require at least three pipes per line and + // a value cell that looks like a hex / px / rem / shadow. + for (const line of body.split('\n')) { + if (!/\|/.test(line)) continue; + const cells = line.split('|').map((c) => c.trim()).filter((c) => c.length > 0); + if (cells.length < 2) continue; + const [name, value] = cells; + if (!name || !value) continue; + if (/[A-Z]/.test(name) || /\s/.test(name)) continue; // skip header rows / human-prose + const key = `${name}=${value}`; + if (seen.has(key)) continue; + if (!/^[#0-9.a-z(),%\s\-]+$/i.test(value)) continue; + seen.add(key); + out.push({ name, value, kind: classifyKind(name, value) }); + } + + return out; +} + +function classifyKind(name: string, value: string): DesignTokenKind { + if (HEX_RE.test(value) || /^rgb|^hsl/.test(value)) return 'color'; + if (/font/i.test(name)) return 'typography'; + if (/(radius|rounded)/i.test(name)) return 'radius'; + if (/(shadow|elevation)/i.test(name)) return 'shadow'; + if (/(space|gap|padding|margin)/i.test(name) || /^(\d+(?:\.\d+)?)(?:px|rem|em)$/.test(value)) return 'spacing'; + return 'color'; +} + +// --- internals ----------------------------------------------------- + +interface IndexedDesignSystem { + byValue: Map; // exact value (case-preserving) + byNormalisedHex: Map; // #aabbcc lowercase + byFuzzyName: Map; // strip prefix + lowercase +} + +function indexDesignSystem(tokens: DesignSystemToken[]): IndexedDesignSystem { + const byValue = new Map(); + const byNormalisedHex = new Map(); + const byFuzzyName = new Map(); + for (const t of tokens) { + push(byValue, t.value, t); + const norm = normaliseHex(t.value); + if (norm) push(byNormalisedHex, norm, t); + push(byFuzzyName, fuzzyName(t.name), t); + } + return { byValue, byNormalisedHex, byFuzzyName }; +} + +function push(map: Map, key: K, value: V): void { + const arr = map.get(key); + if (arr) arr.push(value); else map.set(key, [value]); +} + +function fuzzyName(name: string): string { + return name + .toLowerCase() + .replace(/^-+/, '') + .replace(/^(?:ds-|odds-|theme-)/, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function normaliseHex(value: string): string | null { + const m = HEX_RE.exec(value.trim()); + if (!m) return null; + let hex = m[1]!.toLowerCase(); + if (hex.length === 3) hex = hex.split('').map((c) => c + c).join(''); + if (hex.length === 4) hex = hex.split('').map((c) => c + c).join(''); + return `#${hex}`; +} + +interface MatchOutcome { + match?: TokenMapMatch; + unmatched: TokenMapUnmatched; +} + +function matchOne( + kind: keyof Pick, + entry: DesignTokenEntry, + index: IndexedDesignSystem, +): MatchOutcome { + const tokenKind: DesignTokenKind = kindOf(kind); + const sourceValue = entry.value; + const sourceName = entry.name; + + // 1. Exact value match. + const exact = index.byValue.get(sourceValue); + if (exact && exact.length > 0) { + const target = pickKindMatch(exact, tokenKind); + if (target) return wrapMatch(target, sourceValue, sourceName, 'exact', tokenKind, entry.sources); + } + + // 2. Normalised hex. + if (tokenKind === 'color') { + const norm = normaliseHex(sourceValue); + if (norm) { + const hits = index.byNormalisedHex.get(norm); + if (hits && hits.length > 0) { + const target = pickKindMatch(hits, tokenKind); + if (target) return wrapMatch(target, sourceValue, sourceName, 'normalised-hex', tokenKind, entry.sources); + } + } + } + + // 3. Fuzzy name match (only when source token has a name). + if (sourceName) { + const hits = index.byFuzzyName.get(fuzzyName(sourceName)); + if (hits && hits.length > 0) { + const target = pickKindMatch(hits, tokenKind); + if (target) return wrapMatch(target, sourceValue, sourceName, 'name', tokenKind, entry.sources); + } + } + + return { + unmatched: { + source: sourceValue, + ...(sourceName ? { sourceName } : {}), + kind: tokenKind, + reason: 'no-target-equivalent', + }, + }; +} + +function kindOf(bucket: keyof Pick): DesignTokenKind { + switch (bucket) { + case 'colors': return 'color'; + case 'typography': return 'typography'; + case 'spacing': return 'spacing'; + case 'radius': return 'radius'; + case 'shadow': return 'shadow'; + } +} + +function pickKindMatch(candidates: DesignSystemToken[], kind: DesignTokenKind): DesignSystemToken | undefined { + const sameKind = candidates.find((c) => c.kind === kind); + if (sameKind) return sameKind; + return candidates[0]; +} + +function wrapMatch( + target: DesignSystemToken, + sourceValue: string, + sourceName: string | undefined, + via: TokenMapMatch['via'], + kind: DesignTokenKind, + sources: string[], +): MatchOutcome { + const match: TokenMapMatch = { + source: sourceValue, + target: target.name, + targetValue: target.value, + via, + kind, + sources: sources.slice(), + } as TokenMapMatch; + if (sourceName) match.sourceName = sourceName; + return { + match, + unmatched: { source: sourceValue, kind, reason: 'no-target-equivalent' }, + }; +} + +async function pathExists(p: string): Promise { + try { await fsp.access(p); return true; } catch { return false; } +} + +function digestObject(obj: unknown): string { + return createHash('sha1').update(JSON.stringify(obj)).digest('hex'); +} diff --git a/apps/daemon/src/plugins/index.ts b/apps/daemon/src/plugins/index.ts index 4ae8d72ae..9582e5fb1 100644 --- a/apps/daemon/src/plugins/index.ts +++ b/apps/daemon/src/plugins/index.ts @@ -10,6 +10,7 @@ export * from './atoms/diff-review.js'; export * from './atoms/handoff.js'; export * from './atoms/patch-edit.js'; export * from './atoms/rewrite-plan.js'; +export * from './atoms/token-map.js'; export * from './bundled.js'; export * from './connector-gate.js'; export * from './export.js'; diff --git a/apps/daemon/tests/plugins-token-map.test.ts b/apps/daemon/tests/plugins-token-map.test.ts new file mode 100644 index 000000000..d674c5298 --- /dev/null +++ b/apps/daemon/tests/plugins-token-map.test.ts @@ -0,0 +1,194 @@ +// Phase 6/7 entry slice — token-map atom impl. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + parseDesignSystemTokens, + runTokenMap, + type DesignSystemTokenBag, +} from '../src/plugins/atoms/token-map.js'; +import type { DesignExtractReport } from '../src/plugins/atoms/design-extract.js'; + +let cwd: string; + +beforeEach(async () => { + cwd = await mkdtemp(path.join(os.tmpdir(), 'od-token-map-')); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +const ds: DesignSystemTokenBag = { + id: 'fixture-ds', + tokens: [ + { name: '--ds-color-primary', value: '#5b8def', kind: 'color' }, + { name: '--ds-color-fg', value: '#111111', kind: 'color' }, + { name: '--ds-radius-md', value: '12px', kind: 'radius' }, + { name: 'ds-spacing-2', value: '8px', kind: 'spacing' }, + { name: 'ds-font-body', value: 'Inter', kind: 'typography' }, + ], +}; + +const codeTokens = (over: Partial = {}): DesignExtractReport => ({ + colors: [], + typography: [], + spacing: [], + radius: [], + shadow: [], + scannedFiles: [], + warnings: [], + endedAt: new Date().toISOString(), + ...over, +}); + +describe('runTokenMap — match strategies', () => { + it('matches by exact value', async () => { + const source = codeTokens({ + colors: [{ kind: 'color', value: '#5b8def', sources: ['Button.tsx:3'], usage: ['Button.tsx'] }], + }); + const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds }); + expect(report.colors).toHaveLength(1); + expect(report.colors[0]?.target).toBe('--ds-color-primary'); + expect(report.colors[0]?.via).toBe('exact'); + expect(report.colors[0]?.sources).toEqual(['Button.tsx:3']); + expect(report.unmatched).toEqual([]); + }); + + it('matches by normalised hex (#abc → #aabbcc)', async () => { + const dsShort: DesignSystemTokenBag = { + tokens: [{ name: '--ds-fg', value: '#abc', kind: 'color' }], + }; + const source = codeTokens({ + colors: [{ kind: 'color', value: '#aabbcc', sources: [], usage: [] }], + }); + const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: dsShort }); + expect(report.colors[0]?.via).toBe('normalised-hex'); + expect(report.colors[0]?.target).toBe('--ds-fg'); + }); + + it('matches by fuzzy name when source carries a name', async () => { + const source = codeTokens({ + colors: [{ kind: 'color', name: '--color-primary', value: '#999999', sources: [], usage: [] }], + }); + const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds }); + // Falls back to name match because the value doesn't match exact / normalised. + expect(report.colors[0]?.via).toBe('name'); + expect(report.colors[0]?.target).toBe('--ds-color-primary'); + }); + + it("records unmatched tokens with reason='no-target-equivalent'", async () => { + const source = codeTokens({ + colors: [{ kind: 'color', value: '#abc999', sources: ['x.css:1'], usage: ['x.css'] }], + }); + const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds }); + expect(report.colors).toEqual([]); + expect(report.unmatched).toHaveLength(1); + expect(report.unmatched[0]).toMatchObject({ + source: '#abc999', + kind: 'color', + reason: 'no-target-equivalent', + }); + }); + + it("records target collisions when multiple sources map to the same target", async () => { + const source = codeTokens({ + colors: [ + { kind: 'color', value: '#5B8DEF', sources: ['a.css:1'], usage: ['a.css'] }, + { kind: 'color', value: '#5b8def', sources: ['b.css:1'], usage: ['b.css'] }, + ], + }); + const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds }); + expect(report.colors).toHaveLength(1); + expect(report.unmatched).toHaveLength(1); + expect(report.unmatched[0]).toMatchObject({ + reason: 'target-collision', + hint: expect.stringMatching(/already mapped/), + }); + }); + + it('throws under strict mode when any source is unmatched', async () => { + const source = codeTokens({ + colors: [{ kind: 'color', value: '#deadbe', sources: [], usage: [] }], + }); + await expect(runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds, strict: true })) + .rejects.toThrow(/strict/); + }); +}); + +describe('runTokenMap — multi-kind matching', () => { + it('crosswalks spacing / radius / typography buckets', async () => { + const source = codeTokens({ + spacing: [{ kind: 'spacing', value: '8px', sources: [], usage: [] }], + radius: [{ kind: 'radius', value: '12px', sources: [], usage: [] }], + typography: [{ kind: 'typography', value: 'Inter', sources: [], usage: [] }], + }); + const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds }); + expect(report.spacing[0]?.target).toBe('ds-spacing-2'); + expect(report.radius[0]?.target).toBe('--ds-radius-md'); + expect(report.typography[0]?.target).toBe('ds-font-body'); + }); +}); + +describe('runTokenMap — disk inputs + outputs', () => { + it('reads code/tokens.json when source is omitted + persists every bucket file', async () => { + await mkdir(path.join(cwd, 'code'), { recursive: true }); + const onDisk = codeTokens({ + colors: [{ kind: 'color', value: '#111111', sources: ['x:1'], usage: ['x'] }], + }); + await writeFile(path.join(cwd, 'code', 'tokens.json'), JSON.stringify(onDisk)); + await runTokenMap({ cwd, designSystem: ds }); + const colors = JSON.parse(await readFile(path.join(cwd, 'token-map', 'colors.json'), 'utf8')); + const meta = JSON.parse(await readFile(path.join(cwd, 'token-map', 'meta.json'), 'utf8')); + const unmatched = JSON.parse(await readFile(path.join(cwd, 'token-map', 'unmatched.json'), 'utf8')); + expect(colors[0]?.target).toBe('--ds-color-fg'); + expect(meta.sourceKind).toBe('code'); + expect(meta.designSystemId).toBe('fixture-ds'); + expect(meta.atomDigest.length).toBe(40); + expect(unmatched).toEqual([]); + }); + + it('falls back to figma/tokens.json when code/tokens.json is missing', async () => { + await mkdir(path.join(cwd, 'figma'), { recursive: true }); + const onDisk = codeTokens({ + colors: [{ kind: 'color', value: '#111111', sources: [], usage: [] }], + }); + await writeFile(path.join(cwd, 'figma', 'tokens.json'), JSON.stringify(onDisk)); + const report = await runTokenMap({ cwd, designSystem: ds }); + expect(report.meta.sourceKind).toBe('figma'); + }); + + it('throws a clear error when neither input exists', async () => { + await expect(runTokenMap({ cwd, designSystem: ds })) + .rejects.toThrow(/run design-extract or figma-extract first/); + }); +}); + +describe('parseDesignSystemTokens', () => { + it('extracts CSS custom properties from a DESIGN.md body', () => { + const body = ` +# Design system + +\`\`\`css +:root { + --ds-color-primary: #5b8def; + --ds-radius-md: 12px; +} +\`\`\` +`; + const out = parseDesignSystemTokens(body); + expect(out.find((t) => t.name === '--ds-color-primary')?.value).toBe('#5b8def'); + expect(out.find((t) => t.name === '--ds-radius-md')?.kind).toBe('radius'); + }); + + it('extracts markdown table rows', () => { + const body = ` +| ds-bg | #ffffff | +| ds-fg | #111111 | +`; + const out = parseDesignSystemTokens(body); + expect(out.map((t) => t.name)).toEqual(expect.arrayContaining(['ds-bg', 'ds-fg'])); + }); +});