Skip to content

Commit 1479d78

Browse files
authored
feat(map-tokens): kind-scope the candidate list in the prompt (#201)
1 parent b0a7e44 commit 1479d78

3 files changed

Lines changed: 160 additions & 13 deletions

File tree

packages/experience-design-system-generation/skills/map-tokens.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This is a narrowing fallback, not a classification step. It does not re-classify
1313
All input is embedded inline in the prompt before this file:
1414

1515
- **Generated CDF so far** — design-category, token-typed props only, grouped by component. Every prop shown here already has `$type: "token"` and `$category: "design"`; you do not need to re-verify either. A prop that already carries a `$token.allowed` entry was resolved earlier from source evidence — leave it alone. Only decide for props that arrive with no `$token.allowed` at all.
16-
- **Token path index** — a flat array of `{ "path": "<dot.notation.path>", "type": "<DTCG $type>" }` covering every leaf token in the library. **No `$value` is included** — the mapping decision only needs paths and their DTCG type, not their concrete values. A prop's candidates are always the subset of this index whose `type` matches the prop's `$token.kind`ignore every entry outside that type.
16+
- **Token path index**one or more sections, each a flat list of `path · type` lines (one leaf token per line, `$value` omitted). Every section is already pre-scoped: a section titled with a `$token.kind` (e.g. "color candidates only") contains every leaf token of that type and no others; a prop with that `$token.kind` draws its candidates only from the matching section. The "full tree" section — present only when at least one prop has no `$token.kind`lists every leaf token in the library, unscoped, exactly as before. Never mix candidates across sections; a prop's candidates come from exactly one.
1717
- **Component source references** — the real file text for each component (bounded/truncated), rendered inline as a fenced code block, so you can look for an explicit restriction: a comment naming the valid tokens, or code that validates the prop against a fixed list of token paths. A default value or a `tokenReference` is the prop's *default*, not a restriction — see the decision tree. **You have no filesystem access and no tools — `sourcePath` is a citation label only, never something to open.** When a component's source couldn't be read (moved/deleted since extraction), it's listed separately by path with no code block; for those, there is no source evidence to narrow from — skip the prop.
1818

1919
```typescript
@@ -75,7 +75,7 @@ Emit one JSON object per line. The CLI parses lines starting with `{`. Lines not
7575
- Emit exactly one JSON object per line. No multi-line JSON.
7676
- Only emit a call for a prop that appears in the "Generated CDF so far" section, and only when it arrived **without** an existing `$token.allowed`.
7777
- `token_allowed` is required and must be non-empty. Never emit a call with an empty or missing `token_allowed` — if there's nothing to narrow to, emit no call at all.
78-
- Every path in `token_allowed` must be an individual **leaf** path that exists verbatim in the token path index, and must be of the prop's `$token.kind` type. Never emit a group/prefix path (e.g. `colors.brand`) — the index has no entry for groups, only leaves. Never invent a path, and never include a variant/enum name in place of a real token path — if you can't find a matching path, omit that entry.
78+
- Every path in `token_allowed` must be an individual **leaf** path that exists verbatim in the token path index section matching the prop's `$token.kind` (or the "full tree" section, for a prop with no `$token.kind`). Never emit a group/prefix path (e.g. `colors.brand`) — the index has no entry for groups, only leaves. Never invent a path, and never include a variant/enum name in place of a real token path — if you can't find a matching path, omit that entry.
7979
- `description` is a short internal rationale for the developer reviewing the import — not customer-facing copy.
8080
- No `$value` is available in this step. Reason from paths and `$type` only.
8181

packages/experience-design-system-generation/src/prompt-builder.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,44 @@ function buildTokenPathIndex(tree: TokenTree, prefix = ''): TokenPathIndexEntry[
210210
return entries;
211211
}
212212

213+
/** One line per candidate, e.g. `colors.brand.primary · color` — legible and countable, unlike nested JSON. */
214+
function formatTokenCandidateLines(entries: TokenPathIndexEntry[]): string {
215+
return entries.map((entry) => `${entry.path} · ${entry.type}`).join('\n');
216+
}
217+
218+
/**
219+
* Distinct `$token.kind` values among design-category, token-typed props in
220+
* `cdf`, plus whether any such prop has no `$token.kind` at all. Mirrors
221+
* `filterDesignTokenProps`'s notion of a relevant prop without requiring a
222+
* second pass over the raw CDF.
223+
*/
224+
function collectTokenKinds(cdf: GeneratedCdf): { kinds: string[]; hasUnscoped: boolean } {
225+
const kinds = new Set<string>();
226+
let hasUnscoped = false;
227+
for (const component of Object.values(cdf)) {
228+
const properties = component.$properties;
229+
if (!properties) continue;
230+
for (const prop of Object.values(properties)) {
231+
if (prop.$type !== 'token' || prop.$category !== 'design') continue;
232+
const kind = prop['$token.kind'];
233+
if (typeof kind === 'string' && kind.length > 0) {
234+
kinds.add(kind);
235+
} else {
236+
hasUnscoped = true;
237+
}
238+
}
239+
}
240+
return { kinds: [...kinds].sort(), hasUnscoped };
241+
}
242+
243+
/** Renders one kind-scoped (or, for `kind: null`, full-tree) candidate section. Sections are cumulative, never merged, so each stays independently legible. */
244+
function renderTokenCandidateSection(kind: string | null, entries: TokenPathIndexEntry[]): string {
245+
const label = kind
246+
? `Token path index — ${kind} candidates only`
247+
: 'Token path index — full tree (no $token.kind to scope by)';
248+
return `${label}, one leaf token per line as \`path · type\`, no \`$value\`:\n${formatTokenCandidateLines(entries)}`;
249+
}
250+
213251
function buildPreamble(options: PromptOptions): string {
214252
const {
215253
skill,
@@ -248,11 +286,18 @@ function buildPreamble(options: PromptOptions): string {
248286
}
249287
}
250288
if (tokenTree) {
251-
const index = buildTokenPathIndex(tokenTree);
289+
const index = buildTokenPathIndex(tokenTree).sort((a, b) => a.path.localeCompare(b.path));
252290
if (index.length > 0) {
253-
sections.push(
254-
`Token path index — path and $type only, no $value (JSON):\n\`\`\`json\n${JSON.stringify(index)}\n\`\`\``,
255-
);
291+
if (generatedCdf) {
292+
const { kinds, hasUnscoped } = collectTokenKinds(generatedCdf);
293+
for (const kind of kinds) {
294+
const scoped = index.filter((entry) => entry.type === kind);
295+
if (scoped.length > 0) sections.push(renderTokenCandidateSection(kind, scoped));
296+
}
297+
if (hasUnscoped) sections.push(renderTokenCandidateSection(null, index));
298+
} else {
299+
sections.push(renderTokenCandidateSection(null, index));
300+
}
256301
}
257302
}
258303
if (componentSourceRefs && componentSourceRefs.length > 0) {
@@ -399,9 +444,9 @@ The one tool call you may emit:
399444
Rules:
400445
- Emit exactly one JSON object per line. No multi-line JSON. No markdown fences around the lines.
401446
- Only emit a call for a prop that appears in the "Generated CDF so far" section and does not already carry \`$token.allowed\` — a prop that already has one was resolved from source and must not be contradicted.
402-
- Candidates are scoped to the prop's \`$token.kind\` — only tokens of that type from the "Token path index" can bind; ignore every other entry.
403-
- \`token_allowed\` is a flat list of individual **leaf** token paths — never a group/prefix path. The "Token path index" contains one entry per leaf token only; a path like \`colors.brand\` that groups \`colors.brand.primary\`/\`colors.brand.secondary\` does NOT itself appear in the index and must never be emitted.
404-
- Every path in \`token_allowed\` must exist verbatim in the "Token path index" section and match the prop's \`$token.kind\`. Never invent a path, and never substitute a variant/enum name for a real token path. If a path you'd otherwise suggest is missing from the index, omit it rather than guessing.
447+
- Each "Token path index" section below is already scoped to one \`$token.kind\` — a prop only draws candidates from the section matching its own \`$token.kind\`. A prop with no \`$token.kind\` draws from the "full tree" section instead. Never cross sections.
448+
- \`token_allowed\` is a flat list of individual **leaf** token paths — never a group/prefix path. Each "Token path index" section contains one entry per leaf token only; a path like \`colors.brand\` that groups \`colors.brand.primary\`/\`colors.brand.secondary\` does NOT itself appear in any section and must never be emitted.
449+
- Every path in \`token_allowed\` must exist verbatim in the matching "Token path index" section and match the prop's \`$token.kind\`. Never invent a path, and never substitute a variant/enum name for a real token path. If a path you'd otherwise suggest is missing from the index, omit it rather than guessing.
405450
- \`token_allowed\` is required and must be non-empty when the call is emitted. Restriction requires explicit evidence: a comment naming the valid tokens, or code that validates the prop against a fixed list of token paths.
406451
- A default value or a \`tokenReference\` is the prop's default, not a restriction. On its own it yields no call — narrowing to that one path would leave the marketer a single option. If you narrow on other evidence, the default's path must be in the list.
407452
- A token prop whose type is a union of variant names (\`'primary' | 'secondary'\`) is misclassified — it should be an enum. Do not narrow it; emit nothing and flag it in a prose line.

packages/experience-design-system-generation/test/prompt-builder.test.ts

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,21 +281,123 @@ describe('buildPrompt', () => {
281281
expect(prompt).not.toContain('Widget');
282282
});
283283

284-
it('flattens the token tree to a path + $type index with no $value', async () => {
284+
it('flattens the token tree to one path · type line per candidate, no $value', async () => {
285285
const prompt = await buildPrompt({
286286
skill: 'map-tokens',
287287
mode: 'autonomous',
288288
generatedCdf: GENERATED_CDF,
289289
tokenTree: TOKEN_TREE,
290290
outDir: '/fake/out',
291291
});
292-
expect(prompt).toContain('{"path":"colors.surface.default","type":"color"}');
293-
expect(prompt).toContain('{"path":"colors.brand.primary","type":"color"}');
292+
expect(prompt).toContain('colors.surface.default · color');
293+
expect(prompt).toContain('colors.brand.primary · color');
294294
expect(prompt).toContain('Token path index');
295295
expect(prompt).not.toContain('#ffffff');
296296
expect(prompt).not.toContain('#0066ff');
297297
});
298298

299+
it('kind-scopes the candidate list per property, excluding tokens of other kinds', async () => {
300+
const cdf = {
301+
Card: {
302+
$type: 'component',
303+
$properties: {
304+
bgColor: { $type: 'token', $category: 'design', '$token.kind': 'color' },
305+
},
306+
},
307+
};
308+
const tree = {
309+
colors: { brand: { primary: { $type: 'color', $value: '#0066ff' } } },
310+
spacing: { md: { $type: 'dimension', $value: '16px' } },
311+
};
312+
const prompt = await buildPrompt({
313+
skill: 'map-tokens',
314+
mode: 'autonomous',
315+
generatedCdf: cdf,
316+
tokenTree: tree,
317+
outDir: '/fake/out',
318+
});
319+
expect(prompt).toContain('color candidates only');
320+
expect(prompt).toContain('colors.brand.primary · color');
321+
expect(prompt).not.toContain('spacing.md · dimension');
322+
expect(prompt).not.toContain('Token path index — full tree');
323+
});
324+
325+
it('falls back to the full, unscoped tree for a prop with no $token.kind', async () => {
326+
const cdf = {
327+
Card: {
328+
$type: 'component',
329+
$properties: {
330+
bgColor: { $type: 'token', $category: 'design' },
331+
},
332+
},
333+
};
334+
const tree = {
335+
colors: { brand: { primary: { $type: 'color', $value: '#0066ff' } } },
336+
spacing: { md: { $type: 'dimension', $value: '16px' } },
337+
};
338+
const prompt = await buildPrompt({
339+
skill: 'map-tokens',
340+
mode: 'autonomous',
341+
generatedCdf: cdf,
342+
tokenTree: tree,
343+
outDir: '/fake/out',
344+
});
345+
expect(prompt).toContain('full tree');
346+
expect(prompt).toContain('colors.brand.primary · color');
347+
expect(prompt).toContain('spacing.md · dimension');
348+
});
349+
350+
it('renders a separate scoped section per distinct $token.kind, plus the full-tree fallback when mixed', async () => {
351+
const cdf = {
352+
Card: {
353+
$type: 'component',
354+
$properties: {
355+
bgColor: { $type: 'token', $category: 'design', '$token.kind': 'color' },
356+
gap: { $type: 'token', $category: 'design', '$token.kind': 'dimension' },
357+
unscopedProp: { $type: 'token', $category: 'design' },
358+
},
359+
},
360+
};
361+
const tree = {
362+
colors: { brand: { primary: { $type: 'color', $value: '#0066ff' } } },
363+
spacing: { md: { $type: 'dimension', $value: '16px' } },
364+
};
365+
const prompt = await buildPrompt({
366+
skill: 'map-tokens',
367+
mode: 'autonomous',
368+
generatedCdf: cdf,
369+
tokenTree: tree,
370+
outDir: '/fake/out',
371+
});
372+
expect(prompt).toContain('color candidates only');
373+
expect(prompt).toContain('dimension candidates only');
374+
expect(prompt).toContain('full tree');
375+
const colorIdx = prompt.indexOf('color candidates only');
376+
const dimensionIdx = prompt.indexOf('dimension candidates only');
377+
expect(colorIdx).toBeGreaterThanOrEqual(0);
378+
expect(dimensionIdx).toBeGreaterThan(colorIdx);
379+
});
380+
381+
it('produces an identical prompt across repeated calls with the same input (deterministic ordering)', async () => {
382+
const cdf = {
383+
Widget: {
384+
$type: 'component',
385+
$properties: {
386+
gap: { $type: 'token', $category: 'design', '$token.kind': 'dimension' },
387+
color: { $type: 'token', $category: 'design', '$token.kind': 'color' },
388+
},
389+
},
390+
};
391+
const tree = {
392+
spacing: { md: { $type: 'dimension', $value: '16px' }, sm: { $type: 'dimension', $value: '8px' } },
393+
colors: { brand: { primary: { $type: 'color', $value: '#0066ff' } } },
394+
};
395+
const buildOnce = () =>
396+
buildPrompt({ skill: 'map-tokens', mode: 'autonomous', generatedCdf: cdf, tokenTree: tree, outDir: '/fake/out' });
397+
const [first, second] = await Promise.all([buildOnce(), buildOnce()]);
398+
expect(first).toEqual(second);
399+
});
400+
299401
it('falls back to a path-only listing when content could not be read', async () => {
300402
const prompt = await buildPrompt({
301403
skill: 'map-tokens',
@@ -392,7 +494,7 @@ describe('buildPrompt', () => {
392494
outDir: '/fake/out',
393495
});
394496
expect(prompt).not.toContain('design-category token props only (JSON)');
395-
expect(prompt).not.toContain('Token path index — path and $type only');
497+
expect(prompt).not.toContain('Token path index —');
396498
expect(prompt).not.toContain('### Component source references');
397499
expect(prompt).not.toContain('Component source unavailable for');
398500
});

0 commit comments

Comments
 (0)