feat(plugins): token-map atom impl (Phase 6/7 entry slice)

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:

  <cwd>/token-map/colors.json
  <cwd>/token-map/typography.json
  <cwd>/token-map/spacing.json
  <cwd>/token-map/radius.json
  <cwd>/token-map/shadow.json
  <cwd>/token-map/unmatched.json     [{ source, sourceName?, kind, reason, hint? }]
  <cwd>/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<RewriteStep, 'rationale'> 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>
This commit is contained in:
Cursor Agent 2026-05-09 15:24:22 +00:00
parent 01e94a92be
commit bda5c0e3ed
No known key found for this signature in database
4 changed files with 610 additions and 3 deletions

View file

@ -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<RewriteStep, 'rationale'> {
rationale?: string;
status?: PatchStepStatus;
completedAt?: string;
rationale?: string;
}
export interface PatchReceiptEntry {
@ -160,7 +160,7 @@ export async function applyPatchForStep(input: ApplyPatchInput): Promise<ApplyPa
// Mark the step completed in plan/steps.json + write a receipt.
step.status = 'completed';
step.completedAt = new Date().toISOString();
step.rationale = input.rationale ?? step.rationale;
if (input.rationale) step.rationale = input.rationale;
await fsp.writeFile(stepsPath, JSON.stringify(steps, null, 2) + '\n', 'utf8');
const receiptDir = path.join(cwd, 'plan', 'receipts');
await fsp.mkdir(receiptDir, { recursive: true });

View file

@ -0,0 +1,412 @@
// Phase 6/7 entry slice / spec §10 / §21.3.1 — token-map atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/token-map/.
// The runner crosswalks an extracted token bag (output of
// `design-extract` for code-migration, or `figma-extract` for
// figma-migration) onto the active OD design system's token
// vocabulary and writes the canonical mapping the SKILL.md fragment
// promises:
//
// <cwd>/token-map/colors.json
// <cwd>/token-map/typography.json
// <cwd>/token-map/spacing.json
// <cwd>/token-map/radius.json
// <cwd>/token-map/shadow.json
// <cwd>/token-map/unmatched.json — { source, reason }[]
// <cwd>/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 <cwd>/code/tokens.json
// (preferred for code-migration) or falls back to
// <cwd>/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<TokenMapReport> {
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<string, TokenMapMatch> = 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<string>();
// 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<string, DesignSystemToken[]>; // exact value (case-preserving)
byNormalisedHex: Map<string, DesignSystemToken[]>; // #aabbcc lowercase
byFuzzyName: Map<string, DesignSystemToken[]>; // strip prefix + lowercase
}
function indexDesignSystem(tokens: DesignSystemToken[]): IndexedDesignSystem {
const byValue = new Map<string, DesignSystemToken[]>();
const byNormalisedHex = new Map<string, DesignSystemToken[]>();
const byFuzzyName = new Map<string, DesignSystemToken[]>();
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<K, V>(map: Map<K, V[]>, 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<DesignExtractReport, 'colors' | 'typography' | 'spacing' | 'radius' | 'shadow'>,
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<DesignExtractReport, 'colors' | 'typography' | 'spacing' | 'radius' | 'shadow'>): 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<boolean> {
try { await fsp.access(p); return true; } catch { return false; }
}
function digestObject(obj: unknown): string {
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
}

View file

@ -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';

View file

@ -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> = {}): 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']));
});
});