Skip to content

[Bug]: components-manifest extractor: token refs lost for every other CSS rule and for nested rules; substring class matchers create false-positive groups #6224

Description

@dapsychyoo

What happened?

extractComponentsManifest (packages/contracts/src/design-systems/components-manifest.ts) builds the component-inventory summary that is pasted into the agent's system prompt (## Reference component manifest). While packaging a third-party design system for Open Design we ran the extractor directly against our fixture and found three defects that make the inventory silently wrong. "Silently" is the problem: the agent is told the design system has components it doesn't have, and is not told about token wiring it does have — and then follows the prompt instruction "Prefer these manifest entries over inventing new component shapes" against bad data.

1. Token attribution drops every other CSS rule

extractSelectorTokenReferences uses

const rulePattern = /(?:^|[{}])\s*([^@{}][^{}]*?)\s*\{([^{}]*)\}/g;

The match consumes the rule's closing }. The next rule then has no [{}] anchor left to start from, so it is skipped, and its {/} braces serve as the anchor for the rule after it. Net effect: for consecutive flat rules, every second rule loses its token attribution. A :root block swallows the rule that follows it the same way (the :root rule is matched, continued, and its closing brace is consumed).

Minimal repro (any fixture, run against the built contracts module):

const { extractComponentsManifest } = await import('<repo>/packages/contracts/dist/design-systems/components-manifest.mjs');
const fixtureHtml = `<style>
.btn-a { color: var(--a); }
.btn-b { color: var(--b); }
.btn-c { color: var(--c); }
.btn-d { color: var(--d); }
</style>
<button class="btn-a">x</button>`;
const g = extractComponentsManifest({ brandId: 'repro', fixtureHtml }).groups.find((g) => g.id === 'buttons');
console.log(g.selectors);        // [".btn-a", ".btn-b", ".btn-c", ".btn-d"]
console.log(g.tokenReferences);  // ["--a", "--c"]   ← --b and --d are gone

extractCssSelectors is not affected (its pattern stops at { without consuming the body), which is why the summary shows all selectors but an arbitrary subset of tokens. In realistic CSS the loss is masked semi-randomly — repeated selectors (base rule + state rule) give a selector several chances to land on an "odd" position — so the symptom shows up as plausible-looking but understated token lists rather than an obvious failure.

2. Rules with nested blocks lose their tokens and produce garbage "selectors"

The same [^{}]* body cannot match a rule containing a nested block (CSS nesting is what Tailwind v4 emits for states by default):

const fixtureHtml = `<style>
:root { --chip-bg: #eee; --chip-hover: #ddd; }
.chip-demo { background: var(--chip-bg); &:hover { background: var(--chip-hover); } }
</style>
<span class="chip-demo">chip</span>`;

Result for the badges group: selectors: [".chip-demo"], tokenReferences: [] — and the token map internally attributes --chip-hover to the "selector" background: var(--chip-bg); &:hover, i.e. the declaration text before the nested block. The summary line becomes Badges, chips, and status labels: selectors .chip-demo; tokens none.

3. Substring class matchers create false-positive groups

Several classMatchers are unanchored substring tests: /form/i, /control/i, /input/i (inputs), /grid/i, /layout/i (layout), /status/i (badges), /button/i, /cta/i (buttons), /caption/i (typography). Any Tailwind utility in the fixture markup trips them:

  • transition-transform → contains forminputs group present: true
  • grid-cols-2 → contains gridlayout group present: true
  • the .select-none utility rule matches the \bselect\b selector matcher → inputs gains a selector

With the fixture above (one card, one chip, zero form controls) the prompt summary reads:

Available component groups:
- Form fields and controls: selectors none; tokens none
- Cards and panels: selectors .card-demo; tokens none
- Badges, chips, and status labels: selectors .chip-demo; tokens none
- Layout primitives: selectors none; tokens none

The agent is told the design system has form-field and layout components. It has neither.

Steps to reproduce

  1. node --input-type=module with either snippet above (import extractComponentsManifest from the built @open-design/contracts — same behavior in the packaged app 0.16.1 and in current main; packages/contracts/src/design-systems/components-manifest.ts is unchanged since feat(design-systems): extract component manifests #2051).
  2. Compare groups[].selectors with groups[].tokenReferences, and groups[].present with what the fixture actually contains.
  3. Or end-to-end: install any design-system package whose components.html uses consecutive flat rules / Tailwind v4 nested output / common utilities in markup, and inspect the ## Reference component manifest block in the agent context.

Expected behavior

  1. Every rule's var(--*) references are attributed to its selector — the token list should match what a human reads in the CSS (e.g. a lookahead (?=[{}])-style anchor, or a small brace-depth scanner instead of one regex).
  2. Rules with nested blocks either parse (flatten one level) or at minimum attribute the pre-nesting declarations to the real selector — never to declaration text as a pseudo-selector.
  3. Group membership from class names shouldn't fire on arbitrary substrings of utility classes; anchored patterns (like the existing /^btn(?:$|-)/i) avoid transition-transform → "form fields". If substring matchers stay, it would help to document the naming contract for third-party design systems (root class must match a group's selector matcher, e.g. .card-* not .rack-card) — right now a mismatch produces no warning, just an empty/absent group.

Happy to split this into separate issues if that's easier to track — filing together because all three live in the same extractor and were found in one packaging effort.

Open Design version

0.16.1 (extractor identical in current main)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions