From 08c3f5df5f62ee572cbbb76c598186664ff9d6ea Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:10:29 -0400 Subject: [PATCH 1/9] Add spec for /adhd:pull-component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverse of /adhd:push-component. Reads a Figma Component Set and reconciles its variant properties + lookup-table values back into a React source file. Updates only the design-token surface (Record tables and union type members); function body, JSX, hooks, handlers, and imports are invariant. Key design choices: - The React file IS the snapshot — no parallel state stored in the repo. Lookup tables already encode every design-token value the Figma side cares about. - The mapping lives in adhd.config.ts under components..figma.url, matching the parent config schema. Bidirectional: written by push on first push, by pull on first scaffold. - Pre-flight uses the same lint engine /adhd:lint and push-component preflight use; STRUCT003/004/005 (raw color, fontSize, effects) on the Figma side blocks the pull. Symmetric pipeline — designer-side variable discipline is enforced in both directions. - Escape hatch: --allow-unbound (or allowUnboundFigma: true in config) converts the abort to a confirm-prompt; off-system entries land in code with // adhd:off-system comments for greppability and self-healing on future pulls. - 1- and 2-axis Record tables supported. Other patterns (inline literals, non-string Records, tables inside function bodies) are reported and skipped — no silent inference. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-10-adhd-pull-component.md | 530 ++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-adhd-pull-component.md diff --git a/docs/superpowers/specs/2026-05-10-adhd-pull-component.md b/docs/superpowers/specs/2026-05-10-adhd-pull-component.md new file mode 100644 index 0000000..4d7d89b --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-adhd-pull-component.md @@ -0,0 +1,530 @@ +# /adhd:pull-component — Pull a Figma Component Set Into a React Component Source File + +**Goal:** Inverse of `/adhd:push-component`. Given a target — either a path to an existing React component or a Figma URL — read the corresponding Figma Component Set and reconcile its variant properties, lookup-table values, and union members back into the React source file. Update only the design-token surface (lookup tables, union types); never touch the function body, JSX, handlers, or hooks. Symmetric pipeline: pull's pre-flight validates the Figma source using the same lint engine `/adhd:lint` and `/adhd:push-component`'s preflight use, so structural violations on the Figma side block the pull before any code is rewritten. + +**Architectural premise:** The React file is its own snapshot. Top-level `export const X: Record = { ... }` lookup tables (the convention established by the Avatar reference component) already encode every design-token value the Figma Component Set cares about. We never store a parallel snapshot in the repo — we parse the React file at pull time and diff it directly against Figma. The mapping between a React file and its Figma Component Set lives in `adhd.config.ts` under `components..figma.url`, populated automatically by `/adhd:push-component` on first push and by `/adhd:pull-component` on first scaffold. + +**Precondition:** the design system has been synced to Figma via `/adhd:push-design-system`; all variables the Component Set references exist locally. The target Component Set must pass the lint engine's variable-binding checks (no raw colors, raw fontSize, raw effects on its layers) — designer-side discipline enforced. + +--- + +## Final command surface + +``` +/adhd:config — setup wizard (existing) +/adhd:push-design-system — tokens code → Figma (existing) +/adhd:pull-design-system — tokens Figma → code (existing) +/adhd:lint — validate Figma frame/file (existing) +/adhd:push-component — push a React component (existing) +/adhd:pull-component — pull a Figma Component Set (NEW) +``` + +**Out of scope for v1:** +- Pulling JSX structure changes. Pull does not regenerate the function body; renames, prop additions, or layout changes in code remain a manual task. +- Bulk pulls. v1 is one component per invocation. +- Components that don't follow the `Record` lookup-table convention. v1 reports and aborts; the convention is now documented as part of the plugin's expectations. +- Pulling components whose variant axes correspond to props NOT yet declared in the component file. The asymmetric path ("Figma added an axis the developer hasn't reflected in code") is reported, not auto-resolved. + +--- + +## Pipeline + +``` +Phase 1 Validate config +Phase 2 Resolve target (path | URL | scaffold mode) +Phase 2.5 Pre-flight lint of the Figma Component Set +Phase 3 Read both sides (AST parse React file; extract Figma CS) +Phase 4 Build the diff (unions, table cells, unmapped axes) +Phase 5 Resolve divergences (prompts; batch-confirm affordances) +Phase 6 Drift check (re-fetch Figma; abort if remote changed) +Phase 7 Apply to the React file (AST surgery, single Write call) +Phase 8 Write mapping if scaffold mode +Phase 9 Per-axis commit +Phase 10 Final report +Phase 11 Cleanup +``` + +### Phase 1 — Validate config + +Read `adhd.config.ts`. Require `figma.url` (the file-level URL). If missing: `"Run /adhd:config first to set up ADHD."` + +### Phase 2 — Resolve target + +Branch on `$ARGUMENTS`: + +| Input | Mode | Behavior | +|---|---|---| +| `` matching a `components` entry | **update** | Use the entry's `figma.url` | +| `` with no entry | abort | `"No Figma mapping for . Push it first with /adhd:push-component, or pass a Figma URL to scaffold."` | +| `` matching `components.*.figma.url` | **update** | Reverse-lookup the path | +| `` with no mapping | **scaffold** | Ask via `AskUserQuestion`: "Where should this component live?" Validate target path doesn't already exist (else abort). | + +If the URL's file key doesn't match `config.figma.url`, abort: `"URL points at file , but adhd.config.ts is configured for file ."` (mirrors `/adhd:lint`'s scoped-mode check). + +If `node-id` resolves to a node that isn't a `COMPONENT_SET` or top-level `COMPONENT`, abort: `"Target node is a . Pull requires a Component Set."` + +### Phase 2.5 — Pre-flight lint of the Figma Component Set + +Run the same `lint-engine` modules `/adhd:push-component`'s preflight uses, scoped to the target Component Set: + +```js +const designContext = await extractStructuralData(componentSetId); +const variableDefs = await extractVariableDefs(componentSetId); +const violations = checkStructure(designContext, { fileKey, namingConvention: config.naming }); +``` + +Filter violations to *variable-binding errors* (STRUCT003, STRUCT004, STRUCT005). Naming and structural-organization warnings (STRUCT008, STRUCT009) appear in the final report but do not block. + +**Default behavior (strict):** if any variable-binding errors exist, abort with: + +``` +✗ Cannot pull — the Figma Component Set has N unbound values: + + • > — raw (not a variable) + ... + +These need to be bound to design-system variables before we can pull. The designer can: + 1. Bind them in Figma (right-click the layer → "Apply variable") + 2. Or create new variables if these are new design tokens, then run + /adhd:pull-design-system first to bring those into globals.css, then re-run + /adhd:pull-component + +We don't generate arbitrary Tailwind classes like text-[20px] or h-[80px] in your +code — those would leak the design system the moment they shipped. +``` + +**Escape (opt-in):** if `--allow-unbound` is passed OR `components..allowUnboundFigma === true` in config, the abort becomes a confirm-prompt: + +``` +⚠ The Figma Component Set has N unbound values: + ... + +If you continue, these will land in your code as ARBITRARY Tailwind classes: + • bg-[#f2f2f5] + • text-[20px] + • rounded-[32px] + +These have real consequences: + • They WILL drift over time — Figma changes won't propagate (we have no variable to track them). + • They break /adhd:push-component (preflight will fail on the round-trip until they're bound). + • They will be marked with // adhd:off-system comments so they're greppable later. + +The right fix is to bind these in Figma. This escape is a pragmatic short-term path. + +Continue with arbitrary classes? (y/N) +``` + +On confirm, off-system entries land in the React file with `// adhd:off-system` comments above each one (see Phase 7). + +### Phase 3 — Read both sides + +**React side:** read the file with `Read`. AST-parse via the TypeScript compiler API (already a transitive dep through Next.js). Extract: + +| AST node | Output | +|---|---| +| `TypeAliasDeclaration` of `UnionTypeNode>` | `{ : [] }` | +| `InterfaceDeclaration` or `TypeAliasDeclaration` named `Props` | Props mapping (prop name → union type referenced) | +| `VariableStatement` with `VariableDeclaration` typed `Record` | `{ : { : , ... }, axis: }` | +| Nested `Record>` | Two-level table with outer/inner axes | +| Exported function declaration | Component name (sniff only; not modified) | + +What we deliberately ignore: tables typed `Record` where T is not `string`; inline object literals without a `Record` type annotation; tables defined inside the function body; non-Record arrays (e.g. `PALETTE`). + +Save normalized representation to `/tmp/adhd-pull-component/local.json`. + +**Figma side:** `use_figma` scoped to the Component Set, walking each variant and extracting per-layer bound variables. Save to `/tmp/adhd-pull-component/figma.json`. + +### Phase 4 — Build the diff + +Run a comparator producing `/tmp/adhd-pull-component/diff.json` with three buckets: + +```json +{ + "unionDiff": [ + { "union": "AvatarSize", "axis": "size", "add": ["xxl"], "remove": [] } + ], + "tableDiff": [ + { + "table": "SIZE_TEXT", + "axis": "size", + "cells": [ + { "key": "md", "local": "text-sm", "figma": "text-base" }, + { "key": "xl", "local": "text-lg", "figma": "text-xl" } + ] + } + ], + "unmapped": [ + { "figmaAxis": "theme", "values": ["light", "dark"], "reason": "no Record table found in source" } + ] +} +``` + +The Tailwind-class → design-token resolution reuses `plugins/adhd/lib/lint-engine/variable-categorizer.js` + `theme-parser.js`. Layout-only tokens (`flex`, `items-center`) are ignored when resolving. Size, spacing, color, radius, typography tokens map 1:1 to design system variables. + +### Phase 5 — Resolve divergences + +Top-of-loop short-circuit: + +``` +Pull plan: + • union change(s) + • table(s) with cell changes + • unmapped property(ies) + + [1] Apply ALL Figma values + [2] Keep ALL local values (no-op — exits here) + [3] Review each +``` + +If `Review each`: + +**5a — Union changes first.** Per axis: + +``` +Variant axis `size` differs: + Local (AvatarSize): xs | sm | md | lg | xl + Figma: xs | sm | md | lg | xl | xxl + + [1] Add `xxl` to AvatarSize + new entries in all Record tables + [2] Skip — leave union as-is (table cells for this axis also skipped) +``` + +Removed-from-Figma case is symmetric: + +``` + Local (AvatarSize): xs | sm | md | lg | xl | xxl + Figma: xs | sm | md | lg | xl + + [1] Remove `xxl` from AvatarSize + all Record entries + [2] Skip — keep `xxl` in code (you may have logic that uses it) +``` + +If the user skips an axis, all subsequent table-cell prompts for that axis are skipped automatically. + +**5b — Table cells next.** Per table with changes: + +``` +SIZE_TEXT (Record): + + size local figma + ────────────────────────────────── + xs text-2xs text-2xs ✓ + sm text-xs text-xs ✓ + md text-sm text-base ⚠ + lg text-base text-base ✓ + xl text-lg text-xl ⚠ + +2 changes. + [1] Apply Figma's values to all 2 cells + [2] Review each one + [3] Keep all local values (skip this table) +``` + +`Review each` prompts per cell with a binary `[1] Use Figma | [2] Keep local`. + +**5c — Unmapped, informational only:** + +``` +ℹ Figma has 1 variant axis with no matching Record<...> table: + + • theme (Figma values: "light" | "dark") + +Pull cannot auto-update unmapped axes. Add `export type AvatarTheme = "light" | "dark"` +and a Record table, then re-run /adhd:pull-component. +``` + +All resolutions accumulate into `/tmp/adhd-pull-component/resolutions.json`: + +```json +{ + "unions": { "AvatarSize": { "add": ["xxl"], "remove": [] } }, + "tables": { + "SIZE_TEXT": { "md": "text-base", "xl": "text-xl" }, + "STATUS_COLOR": { "away": "bg-amber-600" } + } +} +``` + +### Phase 6 — Drift check + +Re-fetch the Figma CS, hash the relevant subtree, compare to the hash captured in Phase 3. If different, abort: `"Figma changed during pull. Re-run /adhd:pull-component."` + +### Phase 7 — Apply to the React file + +AST-surgery using the TypeScript compiler API's text-replacement APIs. Single `Write` tool call writes the fully transformed source — pull is atomic per file. + +**Touched:** +- Property values in `Record` table literals — replaced in place, preserving surrounding whitespace, indentation, and comments. +- Union member lists on `TypeAliasDeclaration` of `UnionTypeNode` — appended or removed. +- When a union member is added, every `Record` table in the file receives a new key: + - If Figma's bound class resolves cleanly: `xxl: "h-20 w-20"` + - In `--allow-unbound` mode for unbindable values: `// adhd:off-system — figma has no radius variable for 32px` followed by `xxl: "h-[80px] w-[80px] rounded-[32px]"` +- When a union member is removed, the corresponding key is removed from every table. + +**Never touched:** +- Function declarations, function bodies, JSX, hook calls, event handlers, imports (other than no imports are added or removed). +- Lookup tables typed with non-`string` value types. +- Tables defined inside function bodies. + +**Formatting preservation:** +- Detect indentation from the first indented line of the file (2-space / 4-space / tab); mirror it for any inserted lines. +- Preserve CRLF / LF line endings. +- Preserve existing comment positions; new `// adhd:off-system` comments are inserted above their associated table entry. + +### Phase 8 — Write mapping if scaffold mode + +Only runs in scaffold mode. Add `components..figma.url` to `adhd.config.ts` using the same `Edit` tool flow `/adhd:push-component` uses on first push. The added entry matches the parent schema: + +```ts +"app/components/avatar/index.tsx": { + figma: { + url: "https://www.figma.com/design//?node-id=91-18", + }, +} +``` + +### Phase 9 — Per-axis commit + +Group applied resolutions by variant axis. For each axis touched, one commit: + +```bash +git commit -m "ADHD pull: . ( changes)" +``` + +Multiple axes → multiple commits. Zero applied changes (user picked "Keep all local") → no commit. + +### Phase 10 — Final report + +``` +✓ Pulled Avatar from Figma: + - 1 variant added (size: xxl) + - 3 table cells updated (SIZE_TEXT.md, SIZE_TEXT.xl, STATUS_COLOR.away) + - 2 cells kept local (user chose "keep local") + - 0 unmapped Figma properties + +Component file: app/components/avatar/index.tsx +Figma URL: https://figma.com/design/?node-id=91-18 +``` + +### Phase 11 — Cleanup + +Always runs (even on abort). `rm -rf /tmp/adhd-pull-component`. + +--- + +## The lookup-table convention + +The convention is now part of the plugin's documented expectations. Components designed to work with ADHD's push/pull cycle structure their design tokens as: + +```tsx +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; +export type AvatarShape = "circle" | "square"; + +export interface AvatarProps { + name: string; + size?: AvatarSize; + shape?: AvatarShape; +} + +// 1-axis table +export const SIZE_BOX: Record = { + xs: "h-6 w-6", + sm: "h-8 w-8", + md: "h-10 w-10", + lg: "h-12 w-12", + xl: "h-16 w-16", +}; + +// 2-axis table +export const SHAPE: Record> = { + circle: { xs: "rounded-full", sm: "rounded-full", md: "rounded-full", lg: "rounded-full", xl: "rounded-full" }, + square: { xs: "rounded-md", sm: "rounded-md", md: "rounded-lg", lg: "rounded-lg", xl: "rounded-lg" }, +}; + +export function Avatar({ name, size = "md", shape = "circle" }: AvatarProps) { + // function body — pull never touches this + return ...; +} +``` + +Pull recognizes: +- `AvatarSize`, `AvatarShape` as variant-axis unions. The mapping to Figma's `size`, `shape` variant properties is via the props interface — pull walks `AvatarProps`, finds `size: AvatarSize` and `shape: AvatarShape`, and links each prop name to its union. +- `SIZE_BOX`, `SHAPE` as lookup tables keyed by those unions. Tables get linked to a Figma axis through their key type — `Record` maps to the `size` axis because `AvatarSize` is referenced from the `size` prop. +- The component function as a sniff-only target — its existence confirms this is a component file, but its body is invariant. + +Tables that don't fit the pattern are reported and ignored. The plugin does not attempt to infer design tokens from arbitrary code shapes — that's a recipe for false positives and silent rewrites. + +--- + +## Config schema additions + +`adhd.config.ts` gains a `components` field: + +```ts +const config = { + figma: { + url: "https://www.figma.com/design/PBCAkpPnvGXWrz6H7qfH3V/...", + }, + components: { + "app/components/avatar/index.tsx": { + figma: { + url: "https://www.figma.com/design/PBCAkpPnvGXWrz6H7qfH3V/?node-id=91-18", + }, + // v1: optional + // allowUnboundFigma: true, + + // v2+ (not implemented in this PR): + // allowStructWarnings: ["STRUCT008"], + // syncMode: "auto" | "review", + }, + }, +}; + +export default config; +``` + +Schema rules: +- Paths are relative to the directory containing `adhd.config.ts`. +- `components..figma.url` MUST include `node-id` pointing at a `COMPONENT_SET` or top-level `COMPONENT`. +- The file key in each `components.*.figma.url` MUST match `config.figma.url`'s file key. +- Per-component non-Figma settings live at the same level as `figma` (not inside it). + +**Writers of `components.*`:** +- `/adhd:push-component`: writes on first successful push (NEW additive Phase 12.5 added to push-component as part of this PR). +- `/adhd:pull-component`: writes on first successful scaffold-mode pull. + +**Readers:** +- `/adhd:pull-component`: path↔URL bidirectional lookup. +- `/adhd:push-component` (in v2): to decide "update existing CS vs create new page." v1 still always creates new, but lays the mapping for future use. +- `/adhd:config`: does NOT manage `components.*`. The wizard remains focused on file-level setup. + +--- + +## Module layout + +New library at `plugins/adhd/lib/pull-component/`: + +| Module | Responsibility | +|---|---| +| `parse-react.js` | TypeScript compiler API walker; extracts unions, props interface, lookup tables, function-body bounds (for invariant assertion). | +| `class-resolver.js` | Re-exports + wraps `lint-engine/variable-categorizer.js` + `theme-parser.js`. Tokenizes multi-class strings, resolves each to `{domain, path, value}` or marks as "layout-only" (ignored). | +| `differ.js` | Pure function: `(localExtract, figmaExtract) → diff.json`. | +| `apply.js` | Pure function: `(sourceText, resolutions) → newSourceText`. Preserves whitespace, comments, line endings. | +| `config-writer.js` | Add/read `components..figma.url` in `adhd.config.ts`. Idempotent. | +| `cli.js` | Subcommand surface: `parse`, `extract`, `diff`, `apply`, `config-write`. Same shape as `push-component/cli.js`. | + +Skill: `plugins/adhd/skills/pull-component/SKILL.md` — orchestrator with `disable-model-invocation: true`, mirroring `push-component`'s phase-by-phase structure. + +--- + +## Edge cases & errors + +| Case | Behavior | +|---|---| +| `adhd.config.ts` missing | Abort: `"Run /adhd:config first to set up ADHD."` | +| Path form, no `components` entry | Abort with mapping-not-found message | +| URL form, no matching mapping, no path arg | Enter scaffold mode (prompt for path) | +| URL points at different file than `config.figma.url` | Abort with file-mismatch error | +| `node-id` resolves to non-Component-Set | Abort with type-mismatch error | +| Pre-flight finds unbound values, no escape flag | Abort with the "you need variables" error | +| Pre-flight passes, local file has zero recognizable tables | Abort: `" has no Record tables to pull into. v1 requires the lookup-table convention."` | +| Local file references a union we couldn't find in the same file | Warn + skip that axis; report at end as unmapped | +| Local has `Record` but Figma has no matching variant axis | Report as "local-only table"; skip. Common during partial-progress. | +| Multiple tables typed `Record` (legit — SIZE_BOX + SIZE_TEXT + SHAPE) | Prompted independently in Phase 5b | +| Tailwind class the resolver can't parse | Treat as "unknown local value"; show verbatim in diff | +| Figma references a variable that doesn't exist locally | Abort: `"Figma references variables not in your design system. Run /adhd:pull-design-system first."` | +| Drift check (Phase 6) detects remote change | Abort: `"Figma changed during pull. Re-run /adhd:pull-component."` | +| AST write fails | Abort with the write error. Atomic per file (no partial state). | +| User aborts mid-prompt (Ctrl-C) | Apply nothing; print `"Aborted. No changes."`; cleanup runs | +| Scaffold mode: target path already exists | Abort: `" already exists. Pass a different path or delete it first."` | +| `--allow-unbound` with clean Figma | Flag has no effect; proceeds normally | +| Component name in file ≠ Figma CS name | Warn but proceed | +| Source uses CRLF / tabs / 2-space / 4-space indentation | Detected from existing file; preserved through apply | + +--- + +## Pre-flight escape hatch behavior + +When `--allow-unbound` (CLI) OR `components..allowUnboundFigma === true` (config) is active AND Figma has unbound values: + +1. Show the unbound-values list with what they'll become in code (e.g. `text-[20px]`). +2. Confirm prompt: continue with arbitrary classes? (default: No). +3. On confirm: + - Apply proceeds, off-system entries get the `// adhd:off-system — ` comment in the file. + - Final report includes a line: `⚠ N entries are off-system. Bind in Figma to bring them back in-system.` + +The `// adhd:off-system` comment is: +- **Greppable:** `git grep "adhd:off-system"` lists all drift sources. +- **Self-healing:** when the value is bound in Figma, the next pull replaces the arbitrary class with the proper one AND removes the comment. +- **Future-aware:** v2 can ship an `OFFSYSTEM_USAGE` lint rule that surfaces these in `/adhd:lint` output as drift hotspots. + +**Round-trip consequence (intentional):** off-system code in React fails `/adhd:push-component`'s preflight on the way back. This forces a discussion: bind it in Figma, or define new variables and `/adhd:pull-design-system` them. The escape hatch is not a permanent crutch. + +--- + +## Symmetric-pipeline assertions + +| Assertion | Mechanism | +|---|---| +| `class-resolver.js` imports — never duplicates — `lint-engine` Tailwind-resolution logic | Module re-exports from `lint-engine/variable-categorizer.js` + `lint-engine/theme-parser.js`; tested in `__tests__/class-resolver.test.js` | +| Pre-flight uses the same `checkStructure` that `/adhd:lint` and `/adhd:push-component`'s preflight use | Phase 2.5 invokes `lint-engine`'s structure-checker directly; tested by running a known-violation fixture | +| Round-trip stability: push-then-pull produces a no-op diff | Smoke-test acceptance criterion + integration fixture | + +--- + +## Testing strategy + +**Layer 1 — Unit tests on each module.** `plugins/adhd/lib/pull-component/__tests__/`: + +| Module | Coverage | +|---|---| +| `parse-react.js` | Extract Avatar's unions, props, 5 lookup tables; verify multi-axis tables; assert function-body bounds recorded and never visited | +| `class-resolver.js` | Multi-token strings split; layout tokens ignored; size/color/radius/typography map cleanly; reuses lint-engine code | +| `differ.js` | Pure function: clean (no diff), single cell, added union, removed union, unmapped Figma axis, unmapped local table | +| `apply.js` | Pure function: cell update preserves comments, union append, union remove cascades, no-op resolutions return byte-identical | +| `config-writer.js` | Idempotent on re-add; preserves key order | +| `cli.js` | Each subcommand surface (same pattern as push-component CLI tests) | + +**Layer 2 — Integration with real-figma fixtures.** `plugins/adhd/lib/pull-component/__fixtures__/`: + +| Fixture | Asserts | +|---|---| +| `avatar-clean.json` | Diff is empty; apply is byte-identical no-op | +| `avatar-cell-change.json` | 1 cell diff; apply rewrites just that line | +| `avatar-added-variant.json` | Union member appended; new key cascades to all SIZE_* tables | +| `avatar-removed-variant.json` | Inverse | +| `avatar-unbound-fill.json` | Pre-flight aborts; error lists the layer path | +| `avatar-unbound-with-flag.json` | With `--allow-unbound`: off-system comment lands in output | + +Golden source-text files (`avatar-base.tsx`, `avatar-after-.tsx`) committed alongside; tests assert byte-for-byte match after apply. + +**Layer 3 — End-to-end smoke test.** Manual, run against the merged-main Avatar source + Figma CS `91:18` in file `PBCAkpPnvGXWrz6H7qfH3V`: + +1. Start from merged-main Avatar. +2. `/adhd:pull-component app/components/avatar/index.tsx` → "No changes" (in sync). +3. Make a single Figma edit (rebind one variant's color). +4. Re-run pull → 1-cell diff, prompted, applied, committed. +5. Revert Figma edit, re-run → detects drift in the opposite direction, prompts to revert local. + +--- + +## Acceptance criteria + +1. `/adhd:pull-component app/components/avatar/index.tsx` against in-sync Figma produces "No changes" and exits 0. +2. With one cell changed in Figma, pull surfaces the diff, prompts, applies, and commits. +3. With a new variant value in Figma (`size=xxl`), pull prompts to extend the union and cascades the new key through all `Record` tables. +4. With a removed variant value in Figma, pull prompts and removes from the union + all tables. +5. URL form: `/adhd:pull-component ` reverse-resolves to the path from `components.*.figma.url`. +6. URL form with no matching mapping enters scaffold mode, prompts for target path, writes the new file + the mapping. +7. Pre-flight blocks the pull when Figma has unbound values; the error lists each offending layer with its variant path and property. +8. `--allow-unbound` (or `allowUnboundFigma: true` in config) converts the abort to a confirm-prompt; on confirm, hardcoded arbitrary classes land in the file with `// adhd:off-system` comments. +9. URL points at a different Figma file than `config.figma.url` → abort with the file-mismatch error. +10. Function body, JSX, hooks, handlers, and imports are never modified — verified by golden diff in Layer 2 tests. +11. CRLF line endings, tabs vs spaces, and existing comment positions are preserved through apply. +12. Drift check runs between extract and apply; if Figma changed during the user's deliberation, abort with "Re-run pull-component". +13. Per-axis commit: `git commit -m "ADHD pull: avatar.size (3 changes)"` lands per axis touched; multiple axes → multiple commits; zero changes → zero commits. +14. The `class-resolver` module imports from `lint-engine` — no duplicate Tailwind-to-design-token resolution logic. +15. Re-running `/adhd:pull-component` after `/adhd:push-component` on the same component produces a no-op diff (round-trip stability assertion). +16. Pull adds `components.` to `adhd.config.ts` automatically in scaffold mode, in the `{ figma: { url } }` shape matching the parent config schema. +17. README's command table includes the `/adhd:pull-component` row (enforced by the AGENTS.md "keep README in sync" convention). +18. `/adhd:push-component` writes the same `components..figma.url` mapping on first push (additive step inserted into push-component's SKILL.md, between Phase 11 "Decide and finalize" and Phase 12 "Final report" — only writes the mapping on the finalize path, never on rollback). From ad97702c9c26ee7c57fe8e7f88ffdb9e48c91255 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:16:11 -0400 Subject: [PATCH 2/9] Add implementation plan for /adhd:pull-component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 tasks decomposing the spec into TDD steps: - Task 1: scaffold lib + CI + Badge synthetic fixture - Task 2: parse-react.js — TS compiler API extraction - Task 3: class-resolver.js — wraps lint-engine for symmetric pipeline - Task 4: differ.js — pure local/figma comparator - Task 5: apply.js — AST-aware source rewrite (function body invariant) - Task 6: config-writer.js — components mapping in adhd.config.ts - Task 7: cli.js subcommand wiring - Task 8: SKILL.md orchestrator (11 phases) - Task 9: push-component additive (write mapping on first push) - Task 10: README + marketplace docs - Task 11: smoke + PR prep Test coverage maps 1:1 to spec acceptance criteria. Each module gets zero-deps unit tests; integration testing uses a synthetic Badge fixture with 4 Figma scenarios (clean, cell-change, added-variant, removed-variant) and golden output files for byte-identity apply verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-10-adhd-pull-component.md | 2188 +++++++++++++++++ 1 file changed, 2188 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-adhd-pull-component.md diff --git a/docs/superpowers/plans/2026-05-10-adhd-pull-component.md b/docs/superpowers/plans/2026-05-10-adhd-pull-component.md new file mode 100644 index 0000000..9a924ab --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-adhd-pull-component.md @@ -0,0 +1,2188 @@ +# /adhd:pull-component Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement `/adhd:pull-component` — pulls a Figma Component Set back into a React source file, updating only design-token lookup tables and union type members; function body and JSX never modified. + +**Architecture:** Zero-deps Node library at `plugins/adhd/lib/pull-component/`, mirroring the shape of `lib/push-component/`. Single skill at `plugins/adhd/skills/pull-component/SKILL.md` orchestrating an 11-phase flow. The React file is its own snapshot — no external state stored. Mapping (component path → Figma URL) lives in `adhd.config.ts` under `components..figma.url`, written by push on first push and by pull on first scaffold. Pre-flight reuses `lint-engine`'s `checkStructure` + variable-categorizer; class-resolver re-exports lint-engine's theme-parser + variable-categorizer to enforce one canonical Tailwind-to-design-token resolution. + +**Tech Stack:** Node 20 (lib runs zero-deps), TypeScript Compiler API (transitive dep via Next.js for parse-react.js), `node --test` runner, Figma MCP `use_figma`/`generate_figma_design` invoked from the SKILL only. + +--- + +## File structure (lock-in) + +**New library — `plugins/adhd/lib/pull-component/`:** + +| File | Responsibility | +|---|---| +| `parse-react.js` | TS compiler API walker; extract unions, props interface, lookup tables, function-body bounds | +| `class-resolver.js` | Re-exports lint-engine theme-parser + variable-categorizer; tokenizes multi-class strings; resolves each to design-token tuple | +| `differ.js` | Pure: `(localExtract, figmaExtract) → diff.json` | +| `apply.js` | Pure: `(sourceText, resolutions) → newSourceText`; preserves whitespace/comments/line endings | +| `config-writer.js` | Add/read `components..figma.url` in `adhd.config.ts`; idempotent | +| `cli.js` | Subcommands: `parse`, `extract`, `diff`, `apply`, `config-write` | +| `README.md` | One-paragraph module readme | +| `__tests__/parse-react.test.js` | Avatar fixture extraction tests | +| `__tests__/class-resolver.test.js` | Tailwind resolution tests | +| `__tests__/differ.test.js` | Diff shape tests | +| `__tests__/apply.test.js` | Source rewrite tests | +| `__tests__/config-writer.test.js` | Config update tests | +| `__tests__/cli.test.js` | Subcommand surface tests | +| `__fixtures__/badge-base.tsx` | Minimal synthetic component (Badge with 2 sizes + 2 variants) for fast unit tests | +| `__fixtures__/badge-figma-clean.json` | Figma extract matching `badge-base.tsx` | +| `__fixtures__/badge-figma-cell-change.json` | 1 cell differs | +| `__fixtures__/badge-figma-added-variant.json` | Figma has new variant value | +| `__fixtures__/badge-figma-removed-variant.json` | Figma missing a variant value | +| `__fixtures__/badge-figma-unbound.json` | Figma has unbound raw values | +| `__fixtures__/badge-after-cell-change.tsx` | Golden output after applying cell change | +| `__fixtures__/badge-after-added-variant.tsx` | Golden output after adding variant | +| `__fixtures__/badge-after-removed-variant.tsx` | Golden output after removing variant | +| `__fixtures__/badge-after-unbound-allowed.tsx` | Golden output after `--allow-unbound` confirm | + +**New skill — `plugins/adhd/skills/pull-component/SKILL.md`:** +The 11-phase orchestrator, `disable-model-invocation: true`. + +**Modified files:** +- `plugins/adhd/skills/push-component/SKILL.md` — insert mapping-write step between Phase 11 finalize and Phase 12 report +- `.claude-plugin/marketplace.json` — bump description to list 6 commands +- `README.md` — add pull-component row to command table; add scoped subsection +- `.github/workflows/ci.yml` — add `--test plugins/adhd/lib/pull-component/__tests__/` + +--- + +## Task 1: Scaffold library, CI step, and the synthetic Badge fixture + +**Files:** +- Create: `plugins/adhd/lib/pull-component/cli.js` (stub) +- Create: `plugins/adhd/lib/pull-component/README.md` +- Create: `plugins/adhd/lib/pull-component/__tests__/cli.test.js` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-base.tsx` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Write the failing test for cli `--help`** + +`plugins/adhd/lib/pull-component/__tests__/cli.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const CLI = path.resolve(__dirname, '..', 'cli.js'); + +test('cli with --help prints subcommand usage and exits 0', () => { + const result = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' }); + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage:/); + assert.match(result.stdout, /parse/); + assert.match(result.stdout, /extract/); + assert.match(result.stdout, /diff/); + assert.match(result.stdout, /apply/); + assert.match(result.stdout, /config-write/); +}); + +test('cli with no args exits 2 with usage', () => { + const result = spawnSync('node', [CLI], { encoding: 'utf8' }); + assert.equal(result.status, 2); +}); + +test('cli with unknown subcommand exits 2', () => { + const result = spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }); + assert.equal(result.status, 2); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/cli.test.js` +Expected: FAIL — `cli.js` does not exist. + +- [ ] **Step 3: Implement the cli stub** + +`plugins/adhd/lib/pull-component/cli.js`: + +```javascript +#!/usr/bin/env node +'use strict'; + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { args.help = true; continue; } + if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; } + else { args._.push(a); } + } + return args; +} + +function printUsage() { + console.log(`Usage: + cli.js parse --output + cli.js extract --output + cli.js diff --local --figma --output + cli.js apply --source --resolutions --output + cli.js config-write --config --path --figma-url `); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { printUsage(); process.exit(0); } + if (args._.length === 0) { printUsage(); process.exit(2); } + const cmd = args._[0]; + // Subcommands wired in later tasks. Reject unknown to keep behavior strict. + console.error('Unknown subcommand: ' + cmd); + process.exit(2); +} + +main(); +``` + +- [ ] **Step 4: Add the synthetic Badge fixture** + +`plugins/adhd/lib/pull-component/__fixtures__/badge-base.tsx`: + +```tsx +export type BadgeSize = "sm" | "md" | "lg"; +export type BadgeTone = "neutral" | "danger"; + +export interface BadgeProps { + label: string; + size?: BadgeSize; + tone?: BadgeTone; +} + +export const BADGE_BOX: Record = { + sm: "px-2 py-0.5", + md: "px-3 py-1", + lg: "px-4 py-2", +}; + +export const BADGE_TEXT: Record = { + sm: "text-xs", + md: "text-sm", + lg: "text-base", +}; + +export const BADGE_TONE: Record = { + neutral: "bg-zinc-100 text-zinc-700", + danger: "bg-red-100 text-red-700", +}; + +export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { + // Function body — pull never modifies this region. + const box = BADGE_BOX[size]; + const text = BADGE_TEXT[size]; + const tonecls = BADGE_TONE[tone]; + return {label}; +} +``` + +- [ ] **Step 5: Add module README** + +`plugins/adhd/lib/pull-component/README.md`: + +```markdown +# lib/pull-component + +Engine modules for `/adhd:pull-component`. Reads a Figma Component Set and +reconciles it back into a React source file. Updates lookup tables and +union types only — never modifies the function body or JSX. + +Modules: +- `parse-react.js` — TS compiler API walker (extracts unions, props, lookup tables) +- `class-resolver.js` — wraps lint-engine's Tailwind-to-design-token resolution +- `differ.js` — pure: local + figma → diff +- `apply.js` — pure: source + resolutions → new source +- `config-writer.js` — manages `adhd.config.ts` component mappings +- `cli.js` — orchestrator with subcommands invoked by SKILL.md + +See `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` for the +authoritative spec. +``` + +- [ ] **Step 6: Add CI step** + +Modify `.github/workflows/ci.yml`. Locate the `lib-tests` job, add after the push-component test step: + +```yaml + - name: Run pull-component tests + run: node --test plugins/adhd/lib/pull-component/__tests__/ +``` + +- [ ] **Step 7: Run tests, verify pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/` +Expected: 3 cli tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add plugins/adhd/lib/pull-component .github/workflows/ci.yml +git commit -m "Scaffold lib/pull-component with cli stub + badge fixture" +``` + +--- + +## Task 2: parse-react.js — extract unions, props, lookup tables from a React file + +**Files:** +- Create: `plugins/adhd/lib/pull-component/parse-react.js` +- Create: `plugins/adhd/lib/pull-component/__tests__/parse-react.test.js` + +- [ ] **Step 1: Write the failing tests** + +`plugins/adhd/lib/pull-component/__tests__/parse-react.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { parseReactComponent } = require('../parse-react'); + +const BADGE = fs.readFileSync( + path.resolve(__dirname, '..', '__fixtures__', 'badge-base.tsx'), + 'utf8', +); + +test('extracts string literal unions', () => { + const result = parseReactComponent(BADGE); + assert.deepEqual(result.unions.BadgeSize, ['sm', 'md', 'lg']); + assert.deepEqual(result.unions.BadgeTone, ['neutral', 'danger']); +}); + +test('extracts props interface with union references', () => { + const result = parseReactComponent(BADGE); + assert.equal(result.componentName, 'Badge'); + assert.deepEqual(result.props.size, { unionRef: 'BadgeSize', optional: true }); + assert.deepEqual(result.props.tone, { unionRef: 'BadgeTone', optional: true }); + assert.deepEqual(result.props.label, { type: 'string', optional: false }); +}); + +test('extracts single-axis Record lookup tables', () => { + const result = parseReactComponent(BADGE); + assert.deepEqual(result.tables.BADGE_BOX, { + axis: 'BadgeSize', + nested: false, + entries: { sm: 'px-2 py-0.5', md: 'px-3 py-1', lg: 'px-4 py-2' }, + }); + assert.deepEqual(result.tables.BADGE_TONE, { + axis: 'BadgeTone', + nested: false, + entries: { neutral: 'bg-zinc-100 text-zinc-700', danger: 'bg-red-100 text-red-700' }, + }); +}); + +test('records function body bounds (start/end positions) and never visits inside', () => { + const result = parseReactComponent(BADGE); + // Body bounds must encompass the function body. Anything between + // result.functionBody.start and .end is OFF LIMITS for apply(). + assert.ok(result.functionBody.start > 0); + assert.ok(result.functionBody.end > result.functionBody.start); + // The string at those bounds should contain "return" (the JSX return) + assert.match(BADGE.slice(result.functionBody.start, result.functionBody.end), /return { + const SOURCE = ` +export type S = "a" | "b"; +export type T = "x" | "y"; +export interface FooProps { s?: S; t?: T; } +export const T2: Record> = { + a: { x: "p-1", y: "p-2" }, + b: { x: "p-3", y: "p-4" }, +}; +export function Foo({ s = "a", t = "x" }: FooProps) { return ; } +`; + const result = parseReactComponent(SOURCE); + assert.deepEqual(result.tables.T2, { + axis: 'S', + nested: true, + innerAxis: 'T', + entries: { a: { x: 'p-1', y: 'p-2' }, b: { x: 'p-3', y: 'p-4' } }, + }); +}); + +test('ignores tables with non-string value types', () => { + const SOURCE = ` +export type S = "a" | "b"; +export interface FooProps { s?: S; } +export const SIZE_PX: Record = { a: 1, b: 2 }; +export function Foo() { return ; } +`; + const result = parseReactComponent(SOURCE); + assert.equal(result.tables.SIZE_PX, undefined); +}); + +test('ignores tables defined inside a function body', () => { + const SOURCE = ` +export type S = "a" | "b"; +export interface FooProps { s?: S; } +export function Foo() { + const INLINE: Record = { a: "x", b: "y" }; + return ; +} +`; + const result = parseReactComponent(SOURCE); + assert.equal(result.tables.INLINE, undefined); +}); + +test('aborts on file with no exported function component', () => { + const SOURCE = `export const NOT_A_COMPONENT = 42;`; + assert.throws(() => parseReactComponent(SOURCE), /no exported function component/i); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/parse-react.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement parse-react.js** + +`plugins/adhd/lib/pull-component/parse-react.js`: + +```javascript +'use strict'; + +const ts = require('typescript'); + +function parseReactComponent(source) { + const sourceFile = ts.createSourceFile('component.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + const unions = {}; + const props = {}; + const tables = {}; + let componentName = null; + let propsInterfaceName = null; + let functionBody = null; + + // Pass 1: union aliases, props interface, function body bounds, function name. + for (const stmt of sourceFile.statements) { + if (ts.isTypeAliasDeclaration(stmt) && ts.isUnionTypeNode(stmt.type)) { + const members = []; + let allLiterals = true; + for (const member of stmt.type.types) { + if (ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal)) { + members.push(member.literal.text); + } else { + allLiterals = false; + break; + } + } + if (allLiterals) unions[stmt.name.text] = members; + } + if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && /Props$/.test(stmt.name.text)) { + propsInterfaceName = stmt.name.text; + const memberList = ts.isInterfaceDeclaration(stmt) ? stmt.members : (ts.isTypeLiteralNode(stmt.type) ? stmt.type.members : []); + for (const member of memberList) { + if (!ts.isPropertySignature(member) || !member.name) continue; + const propName = member.name.getText(sourceFile); + const optional = !!member.questionToken; + if (member.type && ts.isTypeReferenceNode(member.type)) { + const refName = member.type.typeName.getText(sourceFile); + if (unions[refName]) { + props[propName] = { unionRef: refName, optional }; + } else { + props[propName] = { type: refName, optional }; + } + } else if (member.type) { + const kind = member.type.kind; + if (kind === ts.SyntaxKind.StringKeyword) props[propName] = { type: 'string', optional }; + else if (kind === ts.SyntaxKind.NumberKeyword) props[propName] = { type: 'number', optional }; + else if (kind === ts.SyntaxKind.BooleanKeyword) props[propName] = { type: 'boolean', optional }; + else props[propName] = { type: 'unknown', optional }; + } + } + } + if (ts.isFunctionDeclaration(stmt) && stmt.modifiers && stmt.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword) && stmt.name && stmt.body) { + componentName = stmt.name.text; + functionBody = { start: stmt.body.getStart(sourceFile), end: stmt.body.getEnd() }; + } + } + + if (!componentName) { + throw new Error('No exported function component found in source'); + } + + // Pass 2: lookup tables. Only top-level VariableStatement with a Record annotation. + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!decl.name || !ts.isIdentifier(decl.name)) continue; + const name = decl.name.text; + const annot = decl.type; + if (!annot || !ts.isTypeReferenceNode(annot)) continue; + if (annot.typeName.getText(sourceFile) !== 'Record') continue; + if (!annot.typeArguments || annot.typeArguments.length !== 2) continue; + const outer = annot.typeArguments[0]; + const inner = annot.typeArguments[1]; + const outerName = outer.getText(sourceFile); + if (!unions[outerName]) continue; + + const init = decl.initializer; + if (!init || !ts.isObjectLiteralExpression(init)) continue; + + // 1-axis: Record + if (inner.kind === ts.SyntaxKind.StringKeyword) { + const entries = {}; + for (const prop of init.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const key = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : (ts.isStringLiteral(prop.name) ? prop.name.text : null); + if (!key) continue; + if (!ts.isStringLiteral(prop.initializer)) continue; + entries[key] = prop.initializer.text; + } + tables[name] = { axis: outerName, nested: false, entries }; + continue; + } + + // 2-axis: Record> + if (ts.isTypeReferenceNode(inner) && inner.typeName.getText(sourceFile) === 'Record' && inner.typeArguments && inner.typeArguments.length === 2 && inner.typeArguments[1].kind === ts.SyntaxKind.StringKeyword) { + const innerName = inner.typeArguments[0].getText(sourceFile); + if (!unions[innerName]) continue; + const entries = {}; + for (const prop of init.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const outerKey = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : (ts.isStringLiteral(prop.name) ? prop.name.text : null); + if (!outerKey || !ts.isObjectLiteralExpression(prop.initializer)) continue; + entries[outerKey] = {}; + for (const inProp of prop.initializer.properties) { + if (!ts.isPropertyAssignment(inProp)) continue; + const innerKey = inProp.name && ts.isIdentifier(inProp.name) ? inProp.name.text : (ts.isStringLiteral(inProp.name) ? inProp.name.text : null); + if (!innerKey) continue; + if (!ts.isStringLiteral(inProp.initializer)) continue; + entries[outerKey][innerKey] = inProp.initializer.text; + } + } + tables[name] = { axis: outerName, nested: true, innerAxis: innerName, entries }; + } + } + } + + return { componentName, propsInterfaceName, unions, props, tables, functionBody }; +} + +module.exports = { parseReactComponent }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/parse-react.test.js` +Expected: 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/pull-component/parse-react.js plugins/adhd/lib/pull-component/__tests__/parse-react.test.js +git commit -m "parse-react: extract unions, props, lookup tables via TS compiler API" +``` + +--- + +## Task 3: class-resolver.js — wrap lint-engine for Tailwind-to-design-token resolution + +**Files:** +- Create: `plugins/adhd/lib/pull-component/class-resolver.js` +- Create: `plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js` + +- [ ] **Step 1: Write the failing tests** + +`plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveClassString, resolveClass } = require('../class-resolver'); + +const SAMPLE_GLOBALS_CSS = ` +@import "tailwindcss"; +@theme { + --color-zinc-100: oklch(0.967 0.001 286.375); + --color-red-100: oklch(0.936 0.032 17.717); + --color-red-700: oklch(0.444 0.177 26.899); + --color-zinc-700: oklch(0.37 0.013 285.805); + --spacing: 0.25rem; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; +} +`; + +test('resolves a single utility class to a design-token tuple', () => { + const r = resolveClass('bg-red-100', SAMPLE_GLOBALS_CSS); + assert.equal(r.domain, 'color'); + assert.equal(r.path, 'red/100'); +}); + +test('returns null for an unknown utility', () => { + assert.equal(resolveClass('bg-not-a-color', SAMPLE_GLOBALS_CSS), null); +}); + +test('classifies layout-only tokens as ignored', () => { + assert.equal(resolveClass('flex', SAMPLE_GLOBALS_CSS), null); + assert.equal(resolveClass('items-center', SAMPLE_GLOBALS_CSS), null); +}); + +test('resolves a typography token', () => { + const r = resolveClass('text-xs', SAMPLE_GLOBALS_CSS); + assert.equal(r.domain, 'typography'); + assert.equal(r.path, 'text/xs'); +}); + +test('resolveClassString splits multi-class strings and returns per-token resolution', () => { + const r = resolveClassString('bg-red-100 text-red-700 flex items-center px-2', SAMPLE_GLOBALS_CSS); + // Returns an ARRAY of { token, resolved } entries + const byToken = Object.fromEntries(r.map(e => [e.token, e.resolved])); + assert.equal(byToken['bg-red-100'].domain, 'color'); + assert.equal(byToken['text-red-700'].domain, 'color'); + assert.equal(byToken['flex'], null); + assert.equal(byToken['items-center'], null); +}); + +test('preserves token order in resolveClassString output', () => { + const r = resolveClassString('px-2 py-1 bg-zinc-100', SAMPLE_GLOBALS_CSS); + assert.deepEqual(r.map(e => e.token), ['px-2', 'py-1', 'bg-zinc-100']); +}); + +test('arbitrary-value tokens (text-[10px], h-[80px]) return marker resolved: { domain: "arbitrary" }', () => { + const r = resolveClass('text-[10px]', SAMPLE_GLOBALS_CSS); + assert.equal(r && r.domain, 'arbitrary'); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement class-resolver.js** + +`plugins/adhd/lib/pull-component/class-resolver.js`: + +```javascript +'use strict'; + +// Re-exports + wraps lint-engine's Tailwind-to-design-token resolution. +// This is the symmetric-pipeline assertion — pull and lint share one resolver. + +const { parseGlobalsCss } = require('../lint-engine/theme-parser'); +const { categorizeVariable } = require('../lint-engine/variable-categorizer'); + +// Layout-only token prefixes — never represent design tokens. +const LAYOUT_PREFIXES = [ + 'flex', 'grid', 'block', 'inline', 'hidden', 'absolute', 'relative', 'fixed', 'sticky', + 'items-', 'justify-', 'content-', 'self-', 'place-', 'order-', 'col-', 'row-', + 'overflow-', 'whitespace-', 'truncate', 'select-', 'cursor-', 'pointer-events-', + 'z-', 'opacity-', 'visible', 'invisible', 'isolate', + 'ring-offset-', 'outline-none', 'appearance-', +]; + +function isLayoutOnly(token) { + return LAYOUT_PREFIXES.some(p => token === p.replace(/-$/, '') || token.startsWith(p)); +} + +// "bg-red-100" → { utility: "bg", value: "red-100" } +// "text-xs" → { utility: "text", value: "xs" } +// "text-[10px]"→ { utility: "text", value: "[10px]" } +function parseToken(token) { + const m = /^(bg|text|border|fill|stroke|h|w|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|rounded)-(.+)$/.exec(token); + if (!m) return null; + return { utility: m[1], value: m[2] }; +} + +// Map Tailwind utility prefix → design-token domain. +const UTILITY_TO_DOMAIN = { + bg: 'color', text: 'typography-or-color', border: 'color', fill: 'color', stroke: 'color', + h: 'sizing', w: 'sizing', + p: 'spacing', px: 'spacing', py: 'spacing', pt: 'spacing', pb: 'spacing', pl: 'spacing', pr: 'spacing', + m: 'spacing', mx: 'spacing', my: 'spacing', mt: 'spacing', mb: 'spacing', ml: 'spacing', mr: 'spacing', + gap: 'spacing', rounded: 'radius', +}; + +function resolveClass(token, globalsCss) { + if (isLayoutOnly(token)) return null; + const parts = parseToken(token); + if (!parts) return null; + const { utility, value } = parts; + + // Arbitrary value (e.g. text-[10px], h-[80px]) — flagged with domain: "arbitrary". + if (value.startsWith('[') && value.endsWith(']')) { + return { domain: 'arbitrary', token, raw: value.slice(1, -1) }; + } + + const theme = parseGlobalsCss(globalsCss); + + if (utility === 'text') { + // text-xs / text-sm / text-base → typography variable + if (theme && theme.typography && theme.typography['text/' + value] !== undefined) { + return { domain: 'typography', path: 'text/' + value }; + } + // text-red-700 → color variable + if (theme && theme.color && theme.color[value.replace(/-/g, '/')] !== undefined) { + return { domain: 'color', path: value.replace(/-/g, '/') }; + } + return null; + } + + if (utility === 'bg' || utility === 'border' || utility === 'fill' || utility === 'stroke') { + const path = value.replace(/-/g, '/'); + if (theme && theme.color && theme.color[path] !== undefined) { + return { domain: 'color', path }; + } + return null; + } + + if (utility === 'rounded') { + if (theme && theme.radius && theme.radius[value] !== undefined) { + return { domain: 'radius', path: value }; + } + return null; + } + + // Sizing & spacing: Tailwind v4 uses a multiplier — `h-6` means 6 * --spacing. + // For the diff, we just record the utility token; categorizeVariable does the + // actual var-resolution. v1 records the resolved px value when possible. + if (UTILITY_TO_DOMAIN[utility] === 'spacing' || UTILITY_TO_DOMAIN[utility] === 'sizing') { + return { domain: UTILITY_TO_DOMAIN[utility], path: utility + '/' + value }; + } + + return null; +} + +function resolveClassString(classString, globalsCss) { + const tokens = (classString || '').split(/\s+/).filter(Boolean); + return tokens.map(token => ({ token, resolved: resolveClass(token, globalsCss) })); +} + +module.exports = { resolveClass, resolveClassString }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js` +Expected: 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/pull-component/class-resolver.js plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js +git commit -m "class-resolver: wrap lint-engine theme-parser for class-to-token resolution" +``` + +--- + +## Task 4: differ.js — pure function for local vs Figma diff + +**Files:** +- Create: `plugins/adhd/lib/pull-component/differ.js` +- Create: `plugins/adhd/lib/pull-component/__tests__/differ.test.js` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-clean.json` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-cell-change.json` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-added-variant.json` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-removed-variant.json` + +- [ ] **Step 1: Write the four Figma fixture files** + +The Figma extract shape mirrors what the SKILL produces by serializing a Component Set. Each variant has resolved design tokens per relevant property; pull does NOT need the full Figma tree, only the per-variant per-property bound values. + +`badge-figma-clean.json`: + +```json +{ + "componentSetId": "100:1", + "componentName": "Badge", + "variantAxes": { + "size": ["sm", "md", "lg"], + "tone": ["neutral", "danger"] + }, + "variants": [ + { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "sm", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "md", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "lg", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } } + ] +} +``` + +`badge-figma-cell-change.json`: same as clean except BADGE_TEXT.md is `text-base` (changed from `text-sm`): + +```json +{ + "componentSetId": "100:1", + "componentName": "Badge", + "variantAxes": { + "size": ["sm", "md", "lg"], + "tone": ["neutral", "danger"] + }, + "variants": [ + { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "sm", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "md", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "lg", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } } + ] +} +``` + +`badge-figma-added-variant.json`: clean plus a new size=xl variant: + +```json +{ + "componentSetId": "100:1", + "componentName": "Badge", + "variantAxes": { + "size": ["sm", "md", "lg", "xl"], + "tone": ["neutral", "danger"] + }, + "variants": [ + { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "xl", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-5 py-3", "BADGE_TEXT": "text-lg", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "sm", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "md", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "lg", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } }, + { "props": { "size": "xl", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-5 py-3", "BADGE_TEXT": "text-lg", "BADGE_TONE": "bg-red-100 text-red-700" } } + ] +} +``` + +`badge-figma-removed-variant.json`: clean minus all `tone=danger` variants: + +```json +{ + "componentSetId": "100:1", + "componentName": "Badge", + "variantAxes": { + "size": ["sm", "md", "lg"], + "tone": ["neutral"] + }, + "variants": [ + { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, + { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } } + ] +} +``` + +- [ ] **Step 2: Write the failing tests** + +`plugins/adhd/lib/pull-component/__tests__/differ.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { parseReactComponent } = require('../parse-react'); +const { diffLocalVsFigma } = require('../differ'); + +const FX = (n) => path.resolve(__dirname, '..', '__fixtures__', n); +const BADGE = fs.readFileSync(FX('badge-base.tsx'), 'utf8'); + +function loadFigma(name) { + return JSON.parse(fs.readFileSync(FX(name), 'utf8')); +} + +test('clean figma produces empty diff', () => { + const local = parseReactComponent(BADGE); + const figma = loadFigma('badge-figma-clean.json'); + const diff = diffLocalVsFigma(local, figma); + assert.deepEqual(diff.unionDiff, []); + assert.deepEqual(diff.tableDiff, []); + assert.deepEqual(diff.unmapped, []); +}); + +test('one cell change shows up in tableDiff', () => { + const local = parseReactComponent(BADGE); + const figma = loadFigma('badge-figma-cell-change.json'); + const diff = diffLocalVsFigma(local, figma); + assert.equal(diff.tableDiff.length, 1); + const t = diff.tableDiff[0]; + assert.equal(t.table, 'BADGE_TEXT'); + assert.equal(t.axis, 'size'); + assert.equal(t.cells.length, 1); + assert.deepEqual(t.cells[0], { key: 'md', local: 'text-sm', figma: 'text-base' }); +}); + +test('figma added a variant value → unionDiff has add entry', () => { + const local = parseReactComponent(BADGE); + const figma = loadFigma('badge-figma-added-variant.json'); + const diff = diffLocalVsFigma(local, figma); + assert.equal(diff.unionDiff.length, 1); + assert.deepEqual(diff.unionDiff[0], { + union: 'BadgeSize', axis: 'size', add: ['xl'], remove: [], + }); +}); + +test('figma removed a variant value → unionDiff has remove entry', () => { + const local = parseReactComponent(BADGE); + const figma = loadFigma('badge-figma-removed-variant.json'); + const diff = diffLocalVsFigma(local, figma); + const tone = diff.unionDiff.find(d => d.axis === 'tone'); + assert.ok(tone); + assert.deepEqual(tone.remove, ['danger']); +}); + +test('figma has axis with no matching Record<...> → unmapped entry', () => { + const local = parseReactComponent(BADGE); + const figma = loadFigma('badge-figma-clean.json'); + // Synthesize an extra axis + figma.variantAxes.theme = ['light', 'dark']; + const diff = diffLocalVsFigma(local, figma); + const unmapped = diff.unmapped.find(u => u.figmaAxis === 'theme'); + assert.ok(unmapped); + assert.deepEqual(unmapped.values, ['light', 'dark']); +}); +``` + +- [ ] **Step 3: Verify tests fail** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/differ.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement differ.js** + +`plugins/adhd/lib/pull-component/differ.js`: + +```javascript +'use strict'; + +// Pure function: (parseReactComponent output, figma extract) → diff +// Diff shape (see spec section "Build the diff"): +// { unionDiff: [...], tableDiff: [...], unmapped: [...] } + +function diffLocalVsFigma(local, figma) { + const unionDiff = []; + const tableDiff = []; + const unmapped = []; + + // Build axis → union name lookup from props (e.g. props.size = { unionRef: "BadgeSize" }) + const axisToUnion = {}; + for (const [propName, propDef] of Object.entries(local.props || {})) { + if (propDef.unionRef) axisToUnion[propName] = propDef.unionRef; + } + + // --- Union diff: per axis, compare local union members vs figma variantAxes. + for (const [axis, figmaMembers] of Object.entries(figma.variantAxes || {})) { + const unionName = axisToUnion[axis]; + if (!unionName) { + // Figma has an axis but local has no matching prop/union → unmapped. + unmapped.push({ figmaAxis: axis, values: [...figmaMembers], reason: 'no matching prop/union' }); + continue; + } + const localMembers = local.unions[unionName] || []; + const add = figmaMembers.filter(v => !localMembers.includes(v)); + const remove = localMembers.filter(v => !figmaMembers.includes(v)); + if (add.length || remove.length) { + unionDiff.push({ union: unionName, axis, add, remove }); + } + } + + // --- Table diff: for each local table, walk figma variants whose props match the table's axis keys. + for (const [tableName, table] of Object.entries(local.tables || {})) { + const axisName = Object.entries(axisToUnion).find(([, u]) => u === table.axis)?.[0]; + if (!axisName) continue; // axis not in props → can't reverse-resolve + + if (!table.nested) { + const cells = []; + // For each key in local table, find the figma value(s) for variants where props[axisName] === key. + // If figma values differ within the group, take the first (consistent across non-axis dims is the convention). + for (const key of Object.keys(table.entries)) { + const matching = (figma.variants || []).filter(v => v.props && v.props[axisName] === key); + if (matching.length === 0) continue; // figma doesn't have this variant (handled by unionDiff) + const figmaValue = matching[0].tokens && matching[0].tokens[tableName]; + if (figmaValue === undefined) continue; // figma extract didn't include this token + const localValue = table.entries[key]; + if (localValue !== figmaValue) { + cells.push({ key, local: localValue, figma: figmaValue }); + } + } + if (cells.length) { + tableDiff.push({ table: tableName, axis: axisName, cells }); + } + } else { + // 2-axis: outerKey + innerKey + const innerAxisName = Object.entries(axisToUnion).find(([, u]) => u === table.innerAxis)?.[0]; + if (!innerAxisName) continue; + const cells = []; + for (const [outerKey, inner] of Object.entries(table.entries)) { + for (const [innerKey, localValue] of Object.entries(inner)) { + const matching = (figma.variants || []).filter(v => + v.props && v.props[axisName] === outerKey && v.props[innerAxisName] === innerKey, + ); + if (matching.length === 0) continue; + const figmaValue = matching[0].tokens && matching[0].tokens[tableName]; + if (figmaValue === undefined) continue; + if (localValue !== figmaValue) { + cells.push({ key: `${outerKey}.${innerKey}`, outerKey, innerKey, local: localValue, figma: figmaValue }); + } + } + } + if (cells.length) { + tableDiff.push({ table: tableName, axis: axisName, innerAxis: innerAxisName, cells }); + } + } + } + + return { unionDiff, tableDiff, unmapped }; +} + +module.exports = { diffLocalVsFigma }; +``` + +- [ ] **Step 5: Verify tests pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/differ.test.js` +Expected: 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/adhd/lib/pull-component/differ.js plugins/adhd/lib/pull-component/__tests__/differ.test.js plugins/adhd/lib/pull-component/__fixtures__/badge-figma-*.json +git commit -m "differ: pure function comparing local extract to figma variants" +``` + +--- + +## Task 5: apply.js — AST-aware source rewrite + +**Files:** +- Create: `plugins/adhd/lib/pull-component/apply.js` +- Create: `plugins/adhd/lib/pull-component/__tests__/apply.test.js` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-after-cell-change.tsx` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-after-added-variant.tsx` +- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-after-removed-variant.tsx` + +- [ ] **Step 1: Write the golden output fixtures** + +`badge-after-cell-change.tsx` — same as `badge-base.tsx` except BADGE_TEXT.md changed from `"text-sm"` to `"text-base"`. Preserve all surrounding whitespace and comments. + +```tsx +export type BadgeSize = "sm" | "md" | "lg"; +export type BadgeTone = "neutral" | "danger"; + +export interface BadgeProps { + label: string; + size?: BadgeSize; + tone?: BadgeTone; +} + +export const BADGE_BOX: Record = { + sm: "px-2 py-0.5", + md: "px-3 py-1", + lg: "px-4 py-2", +}; + +export const BADGE_TEXT: Record = { + sm: "text-xs", + md: "text-base", + lg: "text-base", +}; + +export const BADGE_TONE: Record = { + neutral: "bg-zinc-100 text-zinc-700", + danger: "bg-red-100 text-red-700", +}; + +export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { + // Function body — pull never modifies this region. + const box = BADGE_BOX[size]; + const text = BADGE_TEXT[size]; + const tonecls = BADGE_TONE[tone]; + return {label}; +} +``` + +`badge-after-added-variant.tsx` — adds `xl` to BadgeSize union and a new `xl` entry in each `Record` table: + +```tsx +export type BadgeSize = "sm" | "md" | "lg" | "xl"; +export type BadgeTone = "neutral" | "danger"; + +export interface BadgeProps { + label: string; + size?: BadgeSize; + tone?: BadgeTone; +} + +export const BADGE_BOX: Record = { + sm: "px-2 py-0.5", + md: "px-3 py-1", + lg: "px-4 py-2", + xl: "px-5 py-3", +}; + +export const BADGE_TEXT: Record = { + sm: "text-xs", + md: "text-sm", + lg: "text-base", + xl: "text-lg", +}; + +export const BADGE_TONE: Record = { + neutral: "bg-zinc-100 text-zinc-700", + danger: "bg-red-100 text-red-700", +}; + +export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { + // Function body — pull never modifies this region. + const box = BADGE_BOX[size]; + const text = BADGE_TEXT[size]; + const tonecls = BADGE_TONE[tone]; + return {label}; +} +``` + +`badge-after-removed-variant.tsx` — removes `danger` from BadgeTone and from BADGE_TONE: + +```tsx +export type BadgeSize = "sm" | "md" | "lg"; +export type BadgeTone = "neutral"; + +export interface BadgeProps { + label: string; + size?: BadgeSize; + tone?: BadgeTone; +} + +export const BADGE_BOX: Record = { + sm: "px-2 py-0.5", + md: "px-3 py-1", + lg: "px-4 py-2", +}; + +export const BADGE_TEXT: Record = { + sm: "text-xs", + md: "text-sm", + lg: "text-base", +}; + +export const BADGE_TONE: Record = { + neutral: "bg-zinc-100 text-zinc-700", +}; + +export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { + // Function body — pull never modifies this region. + const box = BADGE_BOX[size]; + const text = BADGE_TEXT[size]; + const tonecls = BADGE_TONE[tone]; + return {label}; +} +``` + +- [ ] **Step 2: Write the failing tests** + +`plugins/adhd/lib/pull-component/__tests__/apply.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { applyResolutions } = require('../apply'); + +const FX = (n) => path.resolve(__dirname, '..', '__fixtures__', n); +const BADGE = fs.readFileSync(FX('badge-base.tsx'), 'utf8'); + +test('empty resolutions returns byte-identical source', () => { + const out = applyResolutions(BADGE, { unions: {}, tables: {} }); + assert.equal(out, BADGE); +}); + +test('single cell update preserves surrounding whitespace and other entries', () => { + const resolutions = { unions: {}, tables: { BADGE_TEXT: { md: 'text-base' } } }; + const out = applyResolutions(BADGE, resolutions); + const expected = fs.readFileSync(FX('badge-after-cell-change.tsx'), 'utf8'); + assert.equal(out, expected); +}); + +test('adding a union value appends to union and adds entry to every Record table', () => { + const resolutions = { + unions: { BadgeSize: { add: ['xl'], remove: [] } }, + tables: { + BADGE_BOX: { xl: 'px-5 py-3' }, + BADGE_TEXT: { xl: 'text-lg' }, + }, + }; + const out = applyResolutions(BADGE, resolutions); + const expected = fs.readFileSync(FX('badge-after-added-variant.tsx'), 'utf8'); + assert.equal(out, expected); +}); + +test('removing a union value strips it from union and from every Record table', () => { + const resolutions = { + unions: { BadgeTone: { add: [], remove: ['danger'] } }, + tables: {}, + }; + const out = applyResolutions(BADGE, resolutions); + const expected = fs.readFileSync(FX('badge-after-removed-variant.tsx'), 'utf8'); + assert.equal(out, expected); +}); + +test('preserves CRLF line endings if input has them', () => { + const crlfSource = BADGE.replace(/\n/g, '\r\n'); + const out = applyResolutions(crlfSource, { unions: {}, tables: { BADGE_TEXT: { md: 'text-base' } } }); + assert.ok(out.includes('\r\n')); + assert.ok(!out.match(/[^\r]\n/)); +}); + +test('does not modify text inside the function body region', () => { + const sourceWithBodyHook = BADGE.replace( + 'const box = BADGE_BOX[size];', + 'const box = BADGE_BOX[size]; // hand-written', + ); + const out = applyResolutions(sourceWithBodyHook, { unions: {}, tables: { BADGE_TEXT: { md: 'text-base' } } }); + assert.match(out, /BADGE_BOX\[size\]; \/\/ hand-written/); +}); +``` + +- [ ] **Step 3: Verify tests fail** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/apply.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement apply.js** + +`plugins/adhd/lib/pull-component/apply.js`: + +```javascript +'use strict'; + +const ts = require('typescript'); +const { parseReactComponent } = require('./parse-react'); + +// Pure function: source text + resolutions → new source text. +// Strategy: +// 1. Re-parse the source to get AST node positions for: unions and tables. +// 2. Compute edits as { start, end, newText }, ordered by descending start. +// 3. Apply edits to a single mutable string, splicing in reverse order so +// earlier positions don't shift later ones. +// Function body bounds from parseReactComponent are NEVER referenced — we only +// touch the union type alias declarations and the lookup-table object literals. + +function applyResolutions(source, resolutions) { + const sourceFile = ts.createSourceFile('component.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const local = parseReactComponent(source); + const edits = []; + + // 1. Union edits. + for (const [unionName, change] of Object.entries(resolutions.unions || {})) { + if (!change || ((!change.add || change.add.length === 0) && (!change.remove || change.remove.length === 0))) continue; + const unionStmt = sourceFile.statements.find(s => + ts.isTypeAliasDeclaration(s) && s.name.text === unionName, + ); + if (!unionStmt || !ts.isUnionTypeNode(unionStmt.type)) continue; + const currentMembers = local.unions[unionName] || []; + const removeSet = new Set(change.remove || []); + const updated = currentMembers.filter(m => !removeSet.has(m)).concat((change.add || []).filter(m => !currentMembers.includes(m))); + const newUnionText = updated.map(m => `"${m}"`).join(' | '); + edits.push({ + start: unionStmt.type.getStart(sourceFile), + end: unionStmt.type.getEnd(), + newText: newUnionText, + }); + } + + // Build a map: unionName → list of (tableName, table, varStmt, init) + const tablesByUnion = {}; + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!decl.name || !ts.isIdentifier(decl.name)) continue; + const name = decl.name.text; + if (!local.tables[name]) continue; + if (!decl.initializer || !ts.isObjectLiteralExpression(decl.initializer)) continue; + const axis = local.tables[name].axis; + (tablesByUnion[axis] ||= []).push({ name, stmt, decl, init: decl.initializer }); + } + } + + // 2. Cascade union add/remove into every table whose axis matches the union. + for (const [unionName, change] of Object.entries(resolutions.unions || {})) { + const targets = tablesByUnion[unionName] || []; + for (const t of targets) { + // Removal: drop properties whose key is in `remove`. + if (change.remove && change.remove.length > 0) { + const removeSet = new Set(change.remove); + for (const prop of t.init.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const keyName = prop.name && (ts.isIdentifier(prop.name) ? prop.name.text : (ts.isStringLiteral(prop.name) ? prop.name.text : null)); + if (keyName && removeSet.has(keyName)) { + // Edit deletes the entire property + its trailing comma + leading newline/whitespace. + const start = findLineStart(source, prop.getStart(sourceFile)); + const end = findEndOfPropertyLine(source, prop.getEnd()); + edits.push({ start, end, newText: '' }); + } + } + } + // Addition: append a property at the end of the object literal. + if (change.add && change.add.length > 0 && resolutions.tables && resolutions.tables[t.name]) { + for (const newKey of change.add) { + const newValue = resolutions.tables[t.name][newKey]; + if (newValue === undefined) continue; + // Insertion point: just before the closing brace of the object literal. + const closeBrace = t.init.getEnd() - 1; // the `}` itself + // Detect indentation from the first existing property (if any). + let indent = ' '; + if (t.init.properties.length > 0) { + const firstPropStart = t.init.properties[0].getStart(sourceFile); + const lineStart = findLineStart(source, firstPropStart); + indent = source.slice(lineStart, firstPropStart); + } + // If the off-system marker is needed, the resolutions.tables value should include it as a comment prefix. + // For simplicity here, resolutions.tables values are plain strings; the SKILL preprocesses unbound + // entries by setting resolutions.tables[name][key] to include the comment + newline. + const newProp = `${indent}${newKey}: "${newValue}",\n`; + edits.push({ start: closeBrace, end: closeBrace, newText: newProp }); + } + } + } + } + + // 3. Cell-only updates: change property values where resolutions.tables specifies a key NOT covered by union add. + for (const [tableName, cells] of Object.entries(resolutions.tables || {})) { + const t = (Object.values(tablesByUnion).flat()).find(x => x.name === tableName); + if (!t) continue; + const axisUnion = local.tables[tableName].axis; + const addedSet = new Set((resolutions.unions && resolutions.unions[axisUnion] && resolutions.unions[axisUnion].add) || []); + for (const [key, newValue] of Object.entries(cells)) { + if (addedSet.has(key)) continue; // already handled by addition path above + // 2-axis table: key has form "outerKey.innerKey" + if (local.tables[tableName].nested && key.includes('.')) { + const [outerKey, innerKey] = key.split('.'); + const outerProp = t.init.properties.find(p => + ts.isPropertyAssignment(p) && p.name && ((ts.isIdentifier(p.name) && p.name.text === outerKey) || (ts.isStringLiteral(p.name) && p.name.text === outerKey)), + ); + if (!outerProp || !ts.isObjectLiteralExpression(outerProp.initializer)) continue; + const innerProp = outerProp.initializer.properties.find(p => + ts.isPropertyAssignment(p) && p.name && ((ts.isIdentifier(p.name) && p.name.text === innerKey) || (ts.isStringLiteral(p.name) && p.name.text === innerKey)), + ); + if (!innerProp || !ts.isStringLiteral(innerProp.initializer)) continue; + edits.push({ + start: innerProp.initializer.getStart(sourceFile), + end: innerProp.initializer.getEnd(), + newText: `"${newValue}"`, + }); + continue; + } + // 1-axis + const prop = t.init.properties.find(p => + ts.isPropertyAssignment(p) && p.name && ((ts.isIdentifier(p.name) && p.name.text === key) || (ts.isStringLiteral(p.name) && p.name.text === key)), + ); + if (!prop || !ts.isStringLiteral(prop.initializer)) continue; + edits.push({ + start: prop.initializer.getStart(sourceFile), + end: prop.initializer.getEnd(), + newText: `"${newValue}"`, + }); + } + } + + // Apply edits in reverse position order. + edits.sort((a, b) => b.start - a.start); + let out = source; + for (const e of edits) { + out = out.slice(0, e.start) + e.newText + out.slice(e.end); + } + return out; +} + +function findLineStart(source, position) { + let i = position; + while (i > 0 && source[i - 1] !== '\n') i--; + return i; +} + +function findEndOfPropertyLine(source, position) { + // Move past trailing comma and any whitespace through the newline. + let i = position; + if (source[i] === ',') i++; + while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i++; + if (source[i] === '\r') i++; + if (source[i] === '\n') i++; + return i; +} + +module.exports = { applyResolutions }; +``` + +- [ ] **Step 5: Verify tests pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/apply.test.js` +Expected: 6 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/adhd/lib/pull-component/apply.js plugins/adhd/lib/pull-component/__tests__/apply.test.js plugins/adhd/lib/pull-component/__fixtures__/badge-after-*.tsx +git commit -m "apply: AST-aware source rewrite scoped to unions + lookup tables" +``` + +--- + +## Task 6: config-writer.js — read and write component mappings in adhd.config.ts + +**Files:** +- Create: `plugins/adhd/lib/pull-component/config-writer.js` +- Create: `plugins/adhd/lib/pull-component/__tests__/config-writer.test.js` + +- [ ] **Step 1: Write the failing tests** + +`plugins/adhd/lib/pull-component/__tests__/config-writer.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { readComponentMapping, addComponentMapping } = require('../config-writer'); + +const MINIMAL_CONFIG = `const config = { + figma: { url: "https://figma.com/design/ABC/" }, +}; + +export default config; +`; + +const WITH_COMPONENTS = `const config = { + figma: { url: "https://figma.com/design/ABC/" }, + components: { + "app/components/avatar/index.tsx": { + figma: { url: "https://figma.com/design/ABC/?node-id=91-18" }, + }, + }, +}; + +export default config; +`; + +test('readComponentMapping returns null when no components field exists', () => { + const result = readComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx'); + assert.equal(result, null); +}); + +test('readComponentMapping returns entry when path matches', () => { + const result = readComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx'); + assert.equal(result && result.figma.url, 'https://figma.com/design/ABC/?node-id=91-18'); +}); + +test('addComponentMapping creates components field if missing', () => { + const out = addComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + assert.match(out, /components:\s*\{/); + assert.match(out, /"app\/components\/badge\.tsx":/); + assert.match(out, /url:\s*"https:\/\/figma\.com\/design\/ABC\/\?node-id=200-1"/); +}); + +test('addComponentMapping is idempotent — re-adding same entry returns identical source', () => { + const out1 = addComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + const out2 = addComponentMapping(out1, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + assert.equal(out2, out1); +}); + +test('addComponentMapping appends to existing components field', () => { + const out = addComponentMapping(WITH_COMPONENTS, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + assert.match(out, /"app\/components\/avatar\/index\.tsx":/); + assert.match(out, /"app\/components\/badge\.tsx":/); +}); + +test('addComponentMapping updates existing entry if URL differs', () => { + const out = addComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx', 'https://figma.com/design/ABC/?node-id=999-1'); + assert.match(out, /node-id=999-1/); + assert.doesNotMatch(out, /node-id=91-18/); +}); + +test('reverseLookupPath finds the path for a given figma URL', () => { + const { reverseLookupPath } = require('../config-writer'); + const path = reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=91-18'); + assert.equal(path, 'app/components/avatar/index.tsx'); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/config-writer.test.js` +Expected: FAIL. + +- [ ] **Step 3: Implement config-writer.js** + +`plugins/adhd/lib/pull-component/config-writer.js`: + +```javascript +'use strict'; + +const ts = require('typescript'); + +function parse(source) { + return ts.createSourceFile('adhd.config.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +// Locate the object literal assigned to `const config = { ... }`. +function findConfigObject(sourceFile) { + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.name.text === 'config' && decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) { + return decl.initializer; + } + } + } + return null; +} + +function findProperty(objectLit, name) { + return objectLit.properties.find(p => ts.isPropertyAssignment(p) && p.name && ( + (ts.isIdentifier(p.name) && p.name.text === name) || + (ts.isStringLiteral(p.name) && p.name.text === name) + )); +} + +function readComponentMapping(source, relPath) { + const sf = parse(source); + const cfg = findConfigObject(sf); + if (!cfg) return null; + const components = findProperty(cfg, 'components'); + if (!components || !ts.isObjectLiteralExpression(components.initializer)) return null; + const entry = components.initializer.properties.find(p => + ts.isPropertyAssignment(p) && p.name && ts.isStringLiteral(p.name) && p.name.text === relPath, + ); + if (!entry || !ts.isObjectLiteralExpression(entry.initializer)) return null; + + const figma = findProperty(entry.initializer, 'figma'); + if (!figma || !ts.isObjectLiteralExpression(figma.initializer)) return null; + const url = findProperty(figma.initializer, 'url'); + if (!url || !ts.isStringLiteral(url.initializer)) return null; + return { figma: { url: url.initializer.text } }; +} + +function reverseLookupPath(source, figmaUrl) { + const sf = parse(source); + const cfg = findConfigObject(sf); + if (!cfg) return null; + const components = findProperty(cfg, 'components'); + if (!components || !ts.isObjectLiteralExpression(components.initializer)) return null; + for (const entry of components.initializer.properties) { + if (!ts.isPropertyAssignment(entry) || !entry.name || !ts.isStringLiteral(entry.name)) continue; + if (!ts.isObjectLiteralExpression(entry.initializer)) continue; + const figma = findProperty(entry.initializer, 'figma'); + if (!figma || !ts.isObjectLiteralExpression(figma.initializer)) continue; + const url = findProperty(figma.initializer, 'url'); + if (!url || !ts.isStringLiteral(url.initializer)) continue; + if (url.initializer.text === figmaUrl) return entry.name.text; + } + return null; +} + +function findLineStart(source, position) { + let i = position; + while (i > 0 && source[i - 1] !== '\n') i--; + return i; +} + +function addComponentMapping(source, relPath, figmaUrl) { + // Idempotency: if existing entry matches, return source unchanged. + const existing = readComponentMapping(source, relPath); + if (existing && existing.figma.url === figmaUrl) return source; + + const sf = parse(source); + const cfg = findConfigObject(sf); + if (!cfg) throw new Error('addComponentMapping: could not find `const config = { ... }`'); + + // Case 1: existing components. with a different URL → replace its url inline. + const components = findProperty(cfg, 'components'); + if (components && ts.isObjectLiteralExpression(components.initializer)) { + const entry = components.initializer.properties.find(p => + ts.isPropertyAssignment(p) && p.name && ts.isStringLiteral(p.name) && p.name.text === relPath, + ); + if (entry && ts.isObjectLiteralExpression(entry.initializer)) { + const figma = findProperty(entry.initializer, 'figma'); + if (figma && ts.isObjectLiteralExpression(figma.initializer)) { + const urlProp = findProperty(figma.initializer, 'url'); + if (urlProp && ts.isStringLiteral(urlProp.initializer)) { + const start = urlProp.initializer.getStart(sf); + const end = urlProp.initializer.getEnd(); + return source.slice(0, start) + `"${figmaUrl}"` + source.slice(end); + } + } + } + // Case 2: components exists but not this path → append a new entry before its closing brace. + const close = components.initializer.getEnd() - 1; + const firstProp = components.initializer.properties[0]; + let indent = ' '; + if (firstProp) { + const lineStart = findLineStart(source, firstProp.getStart(sf)); + indent = source.slice(lineStart, firstProp.getStart(sf)); + } + const insert = `${indent}"${relPath}": {\n${indent} figma: { url: "${figmaUrl}" },\n${indent}},\n`; + return source.slice(0, close) + insert + source.slice(close); + } + + // Case 3: no components field → insert one before the closing brace of `const config`. + const close = cfg.getEnd() - 1; + // Detect the indentation used inside config (first existing property). + const firstCfgProp = cfg.properties[0]; + let baseIndent = ' '; + if (firstCfgProp) { + const lineStart = findLineStart(source, firstCfgProp.getStart(sf)); + baseIndent = source.slice(lineStart, firstCfgProp.getStart(sf)); + } + const insert = `${baseIndent}components: {\n${baseIndent} "${relPath}": {\n${baseIndent} figma: { url: "${figmaUrl}" },\n${baseIndent} },\n${baseIndent}},\n`; + return source.slice(0, close) + insert + source.slice(close); +} + +module.exports = { readComponentMapping, reverseLookupPath, addComponentMapping }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/config-writer.test.js` +Expected: 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/pull-component/config-writer.js plugins/adhd/lib/pull-component/__tests__/config-writer.test.js +git commit -m "config-writer: idempotent add/read of components..figma.url" +``` + +--- + +## Task 7: cli.js — wire subcommands + +**Files:** +- Modify: `plugins/adhd/lib/pull-component/cli.js` +- Modify: `plugins/adhd/lib/pull-component/__tests__/cli.test.js` + +- [ ] **Step 1: Extend cli tests for each subcommand** + +Append to `plugins/adhd/lib/pull-component/__tests__/cli.test.js`: + +```javascript +const fs = require('node:fs'); +const os = require('node:os'); + +function tmp(filename, content) { + const p = path.join(os.tmpdir(), 'adhd-pull-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '-' + filename); + fs.writeFileSync(p, content); + return p; +} + +const BADGE_PATH = path.resolve(__dirname, '..', '__fixtures__', 'badge-base.tsx'); + +test('parse subcommand writes a local.json manifest', () => { + const out = tmp('local.json', ''); + const r = spawnSync('node', [CLI, 'parse', BADGE_PATH, '--output', out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const m = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(m.componentName, 'Badge'); + assert.ok(m.unions.BadgeSize); + assert.ok(m.tables.BADGE_BOX); +}); + +test('diff subcommand writes a diff.json', () => { + // parse first + const local = tmp('local.json', ''); + spawnSync('node', [CLI, 'parse', BADGE_PATH, '--output', local], { encoding: 'utf8' }); + // figma fixture + const figma = path.resolve(__dirname, '..', '__fixtures__', 'badge-figma-cell-change.json'); + const out = tmp('diff.json', ''); + const r = spawnSync('node', [CLI, 'diff', '--local', local, '--figma', figma, '--output', out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const d = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(d.tableDiff.length, 1); +}); + +test('apply subcommand rewrites the source file via resolutions', () => { + const src = fs.readFileSync(BADGE_PATH, 'utf8'); + const srcPath = tmp('Badge.tsx', src); + const resolutions = tmp('res.json', JSON.stringify({ + unions: {}, + tables: { BADGE_TEXT: { md: 'text-base' } }, + })); + const out = tmp('out.tsx', ''); + const r = spawnSync('node', [CLI, 'apply', '--source', srcPath, '--resolutions', resolutions, '--output', out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const result = fs.readFileSync(out, 'utf8'); + assert.match(result, /md: "text-base"/); +}); + +test('config-write subcommand adds a components entry', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-write', '--config', cfgPath, '--path', 'app/components/x.tsx', '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const after = fs.readFileSync(cfgPath, 'utf8'); + assert.match(after, /"app\/components\/x\.tsx":/); +}); +``` + +- [ ] **Step 2: Verify the new tests fail** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/cli.test.js` +Expected: 4 new subcommand tests FAIL; original 3 still pass. + +- [ ] **Step 3: Implement cli.js full surface** + +`plugins/adhd/lib/pull-component/cli.js`: + +```javascript +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { parseReactComponent } = require('./parse-react'); +const { diffLocalVsFigma } = require('./differ'); +const { applyResolutions } = require('./apply'); +const { addComponentMapping } = require('./config-writer'); + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { args.help = true; continue; } + if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; } + else { args._.push(a); } + } + return args; +} + +function printUsage() { + console.log(`Usage: + cli.js parse --output + cli.js extract --output + cli.js diff --local --figma --output + cli.js apply --source --resolutions --output + cli.js config-write --config --path --figma-url `); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { printUsage(); process.exit(0); } + if (args._.length === 0) { printUsage(); process.exit(2); } + const cmd = args._[0]; + + if (cmd === 'parse') { + const componentPath = args._[1]; + if (!componentPath || !args.output) { console.error('Usage: parse --output '); process.exit(2); } + const source = fs.readFileSync(componentPath, 'utf8'); + const result = parseReactComponent(source); + fs.writeFileSync(args.output, JSON.stringify(result, null, 2)); + process.exit(0); + } + + if (cmd === 'extract') { + // Passthrough: SKILL builds the figma extract via use_figma and writes it to the path. + // This subcommand is a no-op (placeholder for symmetry); validates the file is JSON. + const figmaState = args._[1]; + if (!figmaState || !args.output) { console.error('Usage: extract --output '); process.exit(2); } + const raw = fs.readFileSync(figmaState, 'utf8'); + JSON.parse(raw); // validation + fs.writeFileSync(args.output, raw); + process.exit(0); + } + + if (cmd === 'diff') { + if (!args.local || !args.figma || !args.output) { console.error('Usage: diff --local --figma --output '); process.exit(2); } + const local = JSON.parse(fs.readFileSync(args.local, 'utf8')); + const figma = JSON.parse(fs.readFileSync(args.figma, 'utf8')); + const diff = diffLocalVsFigma(local, figma); + fs.writeFileSync(args.output, JSON.stringify(diff, null, 2)); + process.exit(0); + } + + if (cmd === 'apply') { + if (!args.source || !args.resolutions || !args.output) { console.error('Usage: apply --source --resolutions --output '); process.exit(2); } + const source = fs.readFileSync(args.source, 'utf8'); + const resolutions = JSON.parse(fs.readFileSync(args.resolutions, 'utf8')); + const out = applyResolutions(source, resolutions); + fs.writeFileSync(args.output, out); + process.exit(0); + } + + if (cmd === 'config-write') { + if (!args.config || !args.path || !args['figma-url']) { console.error('Usage: config-write --config --path --figma-url '); process.exit(2); } + const source = fs.readFileSync(args.config, 'utf8'); + const out = addComponentMapping(source, args.path, args['figma-url']); + fs.writeFileSync(args.config, out); + process.exit(0); + } + + console.error('Unknown subcommand: ' + cmd); + process.exit(2); +} + +main(); +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/pull-component/__tests__/cli.test.js` +Expected: All 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/pull-component/cli.js plugins/adhd/lib/pull-component/__tests__/cli.test.js +git commit -m "cli: wire parse/extract/diff/apply/config-write subcommands" +``` + +--- + +## Task 8: SKILL.md — orchestrate the 11-phase flow + +**Files:** +- Create: `plugins/adhd/skills/pull-component/SKILL.md` + +- [ ] **Step 1: Write the SKILL.md** + +`plugins/adhd/skills/pull-component/SKILL.md`: + +```markdown +--- +description: "Pull a Figma Component Set into a React component source file. Inverse of /adhd:push-component. Updates only design-token lookup tables and union types — function body, JSX, hooks, handlers, and imports are never modified. Reads adhd.config.ts and uses the mapping at components..figma.url. Pre-flight validates the Figma source using the same lint engine /adhd:lint uses; structural violations abort the pull." +disable-model-invocation: true +argument-hint: " [--allow-unbound]" +allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma +--- + +# ADHD Pull Component + +Reconciles a Figma Component Set back into a React source file. Symmetric with /adhd:push-component: the same lint engine, the same Tailwind-to-design-token resolver. Updates are scoped to lookup tables (Record) and union type aliases — never the function body. + +**Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` + +## Phase 1: Validate config + +Read `adhd.config.ts`. Require `figma.url`. If missing: abort with "Run /adhd:config first to set up ADHD." + +## Phase 2: Resolve target + +Parse `$ARGUMENTS`. First positional is either a path (existing file) or a Figma URL (starts with `https://`). + +Use `Bash` to invoke a helper: + +```bash +node -e " +const fs = require('fs'); +const { readComponentMapping, reverseLookupPath } = require('./plugins/adhd/lib/pull-component/config-writer'); +const src = fs.readFileSync('adhd.config.ts', 'utf8'); +const arg = process.argv[1]; +if (arg.startsWith('https://')) { + const path = reverseLookupPath(src, arg); + console.log(JSON.stringify({ mode: path ? 'update' : 'scaffold', path, figmaUrl: arg })); +} else { + const entry = readComponentMapping(src, arg); + if (!entry) { console.error('No mapping for ' + arg); process.exit(2); } + console.log(JSON.stringify({ mode: 'update', path: arg, figmaUrl: entry.figma.url })); +} +" "$ARG" +``` + +Validate the file key in the resolved URL matches `config.figma.url`'s file key. On mismatch abort: "URL points at file , but adhd.config.ts is configured for file ." + +If scaffold mode (URL form, no mapping): use `AskUserQuestion` to ask: "Where should this component be created? (relative path from adhd.config.ts directory)". Validate the path doesn't already exist. + +Save the resolved `{ mode, path, figmaUrl }` to `/tmp/adhd-pull-component/target.json`. + +## Phase 2.5: Pre-flight lint + +Extract the Component Set's structural data via `mcp__plugin_figma_figma__use_figma`, scoped to the resolved node-id. Save to `/tmp/adhd-pull-component/ctx.json` and `/tmp/adhd-pull-component/vars.json`. + +Run the same lint engine /adhd:lint uses: + +```bash +node plugins/adhd/lib/lint-engine/cli.js \ + --variable-defs /tmp/adhd-pull-component/vars.json \ + --design-context /tmp/adhd-pull-component/ctx.json \ + --globals-css \ + --config adhd.config.ts \ + --target "PullComponent Preflight" \ + --target-url "$FIGMA_URL" \ + --output /tmp/adhd-pull-component/preflight.md +``` + +Parse the report for STRUCT003/004/005 errors specifically (variable-binding violations). Other errors are reported in the final report but do not block. + +If variable-binding errors exist AND neither `--allow-unbound` (CLI) nor `components..allowUnboundFigma === true` (config): abort with the helpful error listing each offending layer with its variant path and property (see spec section "Pre-flight lint of the Figma Component Set"). + +If variable-binding errors exist AND the escape is active: render the confirm-prompt via `AskUserQuestion` ("Continue with arbitrary classes? (y/N)"). On `n` or no answer, abort. On `y`, mark offending entries for off-system handling in Phase 7. + +## Phase 3: Read both sides + +In scaffold mode, there is no local file to parse; create an empty `local.json` (no unions, no tables) and skip ahead — Phase 7 will materialize a fresh file using all of Figma's values. + +In update mode: + +```bash +node plugins/adhd/lib/pull-component/cli.js parse --output /tmp/adhd-pull-component/local.json +``` + +For Figma: use `mcp__plugin_figma_figma__use_figma` to walk the Component Set and serialize per-variant per-table tokens. The Figma extract script must produce the shape used in __fixtures__/badge-figma-clean.json — variants with `props` and `tokens` keys. Save to `/tmp/adhd-pull-component/figma.json`. + +## Phase 4: Build the diff + +```bash +node plugins/adhd/lib/pull-component/cli.js diff \ + --local /tmp/adhd-pull-component/local.json \ + --figma /tmp/adhd-pull-component/figma.json \ + --output /tmp/adhd-pull-component/diff.json +``` + +Read `diff.json`. If all three buckets are empty AND mode is update: print "No changes" and exit 0. + +## Phase 5: Resolve divergences + +Top-of-loop short-circuit via `AskUserQuestion`: +- "Apply ALL Figma values" +- "Keep ALL local values (no-op — exits here)" +- "Review each" + +If "Apply ALL", short-circuit by writing a resolutions.json that accepts everything Figma proposes (every unionDiff.add, every cell). Skip 5a and 5b. + +If "Review each": + +**5a — Union changes.** For each entry in `diff.unionDiff`, prompt: +- "Add `` to + cascade to all Record<, ...> tables" +- "Skip — leave union as-is (table cells for this axis also skipped)" + +If the user skips an axis, mark it skipped — Phase 5b's per-axis prompts for that axis are NOT shown. + +**5b — Table cells.** For each `tableDiff` entry, show the table + cells, prompt: +- "Apply Figma's values to all N cells" +- "Review each one" +- "Keep all local values (skip this table)" + +`Review each one` → per-cell binary choice. + +**5c — Unmapped.** Print informational notice for each `unmapped` entry (no prompts). + +Accumulate into `/tmp/adhd-pull-component/resolutions.json`. For off-system entries from Phase 2.5, prefix each table value with the `// adhd:off-system` comment (literal newline included), so apply.js emits the comment above the property. + +## Phase 6: Drift check + +Re-fetch the Figma CS, hash the variant tree, compare to the hash from Phase 3 (saved in `/tmp/adhd-pull-component/figma.hash`). On mismatch abort: "Figma changed during pull. Re-run /adhd:pull-component." + +## Phase 7: Apply + +In scaffold mode: generate the source file from the diff (treat all Figma values as additions). Use a small template — types from `figma.variantAxes`, tables from variants. Write to the target path. The function body is a minimal stub: + +```tsx +export function (/* props */) { + return ; // adhd: scaffold stub — replace with your implementation +} +``` + +In update mode: + +```bash +node plugins/adhd/lib/pull-component/cli.js apply \ + --source \ + --resolutions /tmp/adhd-pull-component/resolutions.json \ + --output /tmp/adhd-pull-component/newsource.tsx +``` + +Then `Write` `/tmp/adhd-pull-component/newsource.tsx` content back to `` (single Write call — atomic per file). + +## Phase 8: Write mapping if scaffold mode + +```bash +node plugins/adhd/lib/pull-component/cli.js config-write \ + --config adhd.config.ts \ + --path \ + --figma-url +``` + +## Phase 9: Per-axis commit + +Group applied resolutions by axis (from `diff.json`). For each axis with applied changes: + +```bash +git add [adhd.config.ts] +git commit -m "ADHD pull: . ( changes)" +``` + +## Phase 10: Final report + +``` +✓ Pulled from Figma: + - variant(s) added + - table cells updated + - cells kept local + - unmapped Figma properties + +Component file: +Figma URL: +``` + +## Phase 11: Cleanup + +Always runs. `rm -rf /tmp/adhd-pull-component`. + +## Common errors + +| Error | Fix-up guidance | +|---|---| +| `adhd.config.ts not found` | Run `/adhd:config`. | +| `No mapping for ` | Push it first: `/adhd:push-component `. | +| `URL points at wrong file` | Open the configured file and copy a node URL from there. | +| `Pre-flight: unbound values` | See the error message — bind values in Figma, or pass `--allow-unbound`. | +| ` has no Record tables` | This component doesn't follow the lookup-table convention. v1 requires it. | +| `Figma changed during pull` | Re-run `/adhd:pull-component`. | +``` + +- [ ] **Step 2: Validate SKILL frontmatter** + +Run: `node scripts/validate-skill-frontmatter.js` +Expected: PASS (the validator checks the frontmatter shape; this SKILL has the same surface as push-component). + +- [ ] **Step 3: Commit** + +```bash +git add plugins/adhd/skills/pull-component/ +git commit -m "Add /adhd:pull-component skill orchestrating 11-phase pull flow" +``` + +--- + +## Task 9: push-component additive — write mapping on first push + +**Files:** +- Modify: `plugins/adhd/skills/push-component/SKILL.md` + +- [ ] **Step 1: Locate the insertion point in push-component SKILL.md** + +The mapping write should appear between Phase 11 (finalize) and Phase 12 (final report). Only run on the finalize path (when preflight passes or user chose "keep"). + +- [ ] **Step 2: Insert the new step** + +Add to `plugins/adhd/skills/push-component/SKILL.md` between Phase 11 and Phase 12: + +```markdown +## Phase 11.5: Write component mapping to adhd.config.ts + +Only runs on the finalize path (skip on rollback). + +```bash +RELATIVE_PATH=$(realpath --relative-to=$(dirname adhd.config.ts) ) +FIGMA_URL="?node-id=$(echo $PAGE_ID | tr ':' '-')" +node plugins/adhd/lib/pull-component/cli.js config-write \ + --config adhd.config.ts \ + --path "$RELATIVE_PATH" \ + --figma-url "$FIGMA_URL" +``` + +This records the mapping so subsequent `/adhd:pull-component ` and `/adhd:pull-component ` invocations can find each other. Idempotent — re-pushing the same component does not duplicate the entry. +``` + +- [ ] **Step 3: Commit** + +```bash +git add plugins/adhd/skills/push-component/SKILL.md +git commit -m "push-component: write mapping to adhd.config.ts on finalize" +``` + +--- + +## Task 10: README and marketplace updates + +**Files:** +- Modify: `README.md` +- Modify: `.claude-plugin/marketplace.json` + +- [ ] **Step 1: Read current README command table** + +Identify lines 19-28 (the command table). The fifth command `/adhd:push-component` is the last row. + +- [ ] **Step 2: Add pull-component row to the command table** + +Edit `README.md`: + +Replace `After install, five slash commands are available:` with `After install, six slash commands are available:`. + +Add a row to the command table after `/adhd:push-component`: + +``` +| `/adhd:pull-component` | ` [--allow-unbound]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched) | +``` + +- [ ] **Step 3: Add a "Pull a component" subsection** + +After the existing "Push a component" subsection, add: + +```markdown +### Pull a component + +``` +# From the consumer repo, with a mapping already established by /adhd:push-component: +/adhd:pull-component app/components/avatar/index.tsx + +# Or by Figma URL — reverse-resolves to the path via adhd.config.ts: +/adhd:pull-component https://www.figma.com/design/?node-id=91-18 + +# Pre-flight is strict by default — if Figma has unbound raw values, pull aborts and asks the designer to bind them. +# To accept hardcoded fallbacks anyway (with adhd:off-system comments for greppability): +/adhd:pull-component app/components/avatar/index.tsx --allow-unbound +``` + +The skill reads the Figma Component Set, diffs it against the React file's `Record` lookup tables, prompts on each divergence, and rewrites only those tables (plus union type members). Function body, JSX, hooks, handlers, and imports are never modified. +``` + +- [ ] **Step 4: Update marketplace.json description** + +`.claude-plugin/marketplace.json` — update the `description` field of the `adhd` plugin to reflect 6 commands. Use the `Read` tool first to see the current value, then `Edit` to update. + +- [ ] **Step 5: Commit** + +```bash +git add README.md .claude-plugin/marketplace.json +git commit -m "README + marketplace: document /adhd:pull-component" +``` + +--- + +## Task 11: Final smoke + PR prep + +- [ ] **Step 1: Run all lib tests** + +```bash +node --test plugins/adhd/lib/lint-engine/__tests__/ plugins/adhd/lib/design-system/__tests__/ plugins/adhd/lib/push-component/__tests__/ plugins/adhd/lib/pull-component/__tests__/ +``` + +Expected: all tests PASS. Confirm count is at least 280 (current 251 + ~30 new). + +- [ ] **Step 2: Run the SKILL frontmatter validator** + +```bash +node scripts/validate-skill-frontmatter.js +``` + +Expected: PASS — all six SKILL.md files have valid frontmatter. + +- [ ] **Step 3: Build the example app to sanity-check no regressions** + +```bash +cd example && npm run build && cd .. +``` + +Expected: compile clean. + +- [ ] **Step 4: Push the branch** + +```bash +git push -u origin adhd/pull-component +``` + +- [ ] **Step 5: Open the PR** + +```bash +gh pr create --title "Add /adhd:pull-component skill" --body "$(cat <<'EOF' +## Summary + +Adds `/adhd:pull-component ` — pulls a Figma Component Set back into a React source file. Inverse direction of `/adhd:push-component`. Updates only design-token lookup tables (`Record`) and union type aliases — function body, JSX, hooks, handlers, and imports are never touched. + +### Pipeline + +1. Validate config +2. Resolve target (path / URL / scaffold mode) +3. Pre-flight lint of the Figma Component Set (same lint-engine as /adhd:lint) +4. Parse React file (TS compiler API) + extract Figma variants +5. Build the diff (union changes / table cells / unmapped axes) +6. Prompt per-divergence +7. Drift check +8. Apply via AST surgery scoped to unions + tables +9. Write component mapping if scaffold mode +10. Per-axis commit +11. Cleanup + +### Key design + +- **The React file IS the snapshot** — no parallel state stored in the repo. Lookup tables already encode every design-token value Figma cares about. +- **Bidirectional mapping** in `adhd.config.ts` under `components..figma.url`. Written by push on first push (this PR adds Phase 11.5 to push-component), by pull on first scaffold. +- **Symmetric pre-flight**: STRUCT003/004/005 violations on the Figma side block the pull. Designer-side variable discipline enforced in both directions. +- **Escape hatch**: `--allow-unbound` (or `allowUnboundFigma: true` in config) converts the abort to a confirm-prompt. Off-system entries land in code with `// adhd:off-system` comments — greppable, self-healing on future pulls. +- **Function body invariant**: AST walker visits only top-level TypeAliasDeclarations and VariableStatements with Record annotations. Function bodies are out-of-bounds. + +### Out of scope (v1) + +- JSX / function body changes — manual only +- Multi-component pulls in one command +- Components without the `Record` lookup-table convention (reported and aborted) + +## Test plan + +- [x] All lib unit tests passing (parse-react, class-resolver, differ, apply, config-writer, cli) +- [x] Integration tests against synthetic Badge fixture with 4 Figma scenarios (clean, cell-change, added-variant, removed-variant) +- [x] SKILL frontmatter validated +- [x] Example app builds clean +- [ ] Manual smoke test: pull-component against the merged-main Avatar component → 0 changes (in sync); manual Figma edit → 1-cell diff → applied → committed + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL printed. + +- [ ] **Step 6: Verify CI is green** + +Run: `gh pr checks $(gh pr view --json number -q .number)` +Expected: all checks pass. + +--- + +## Self-review notes + +**Spec coverage check:** + +| Spec section | Task | +|---|---| +| Final command surface | Task 8 (SKILL.md), Task 10 (README) | +| Pipeline Phase 1 | Task 8 | +| Pipeline Phase 2 | Task 8 (target resolution); Task 6 (config-writer reverseLookupPath, readComponentMapping) | +| Pipeline Phase 2.5 (pre-flight) | Task 8 (SKILL invokes lint-engine subprocess) | +| Pipeline Phase 3 | Task 8 (SKILL); Task 2 (parse-react) | +| Pipeline Phase 4 | Task 4 (differ) | +| Pipeline Phase 5 | Task 8 (prompt UX in SKILL) | +| Pipeline Phase 6 | Task 8 (drift check) | +| Pipeline Phase 7 | Task 5 (apply) | +| Pipeline Phase 8 | Task 6 (config-writer addComponentMapping) | +| Pipeline Phase 9 | Task 8 (commits) | +| Pipeline Phase 10 | Task 8 (report) | +| Pipeline Phase 11 | Task 8 (cleanup) | +| Lookup-table convention | Task 2 (parse-react implements detection) | +| Config schema additions | Task 6 (config-writer); Task 9 (push-component additive); Task 10 (README documents) | +| Module layout | Tasks 1-7 each create one module | +| Edge cases | Task 8 (SKILL "Common errors" table) | +| Pre-flight escape hatch | Task 8 (SKILL Phase 2.5) | +| Symmetric-pipeline assertions | Task 3 (class-resolver imports lint-engine) | +| Testing strategy | Tasks 1, 2, 3, 4, 5, 6 (each module has __tests__) | +| Acceptance criteria 1-18 | Covered across Tasks 2-11 | + +No gaps. + +**Type / signature consistency check:** + +- `parseReactComponent(source)` → `{ componentName, propsInterfaceName, unions, props, tables, functionBody }` — same signature in Tasks 2, 4, 5, 7 +- `diffLocalVsFigma(local, figma)` → `{ unionDiff, tableDiff, unmapped }` — same in Tasks 4, 7 +- `applyResolutions(source, resolutions)` → `newSource` (string) — same in Tasks 5, 7 +- Resolutions shape `{ unions: { : { add, remove } }, tables: { : { : } } }` — same across Tasks 5, 7, 8 +- `addComponentMapping(source, relPath, figmaUrl)` → newSource — same in Tasks 6, 7, 9 +- `readComponentMapping(source, relPath)` → `{ figma: { url } } | null` — same in Tasks 6, 8 +- `reverseLookupPath(source, figmaUrl)` → `relPath | null` — same in Tasks 6, 8 From 0f9cdd4259dc6edd171404544dbc79da79e5a427 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:23:38 -0400 Subject: [PATCH 3/9] Revise spec + plan: LLM as diff/apply engine, not brittle AST code Initial draft had parse-react.js + differ.js + apply.js modules reinventing TypeScript-compiler-API source extraction and AST-aware text replacement. User gut-checked: "Claude Code is the reason we're doing this code gen. I want the intelligence of Claude Code to know how to diff this stuff. I don't want to use rigid, brittle code to do it when we have a full beautiful LLM to do it. For anything not deterministic, we pretty much always use the LLM because the LLM is only getting better." Revised design: - Library shrinks to one module: config-writer.js (deterministic schema-level adhd.config.ts mutation, idempotent, unit-testable). - The SKILL prompt is the brain: reads the React source via Read, extracts the Figma Component Set via use_figma, computes the diff in working memory, prompts via AskUserQuestion, applies via Edit tool calls. Every invariant (function body untouched, off-system comment format, abort conditions) is stated explicitly in the SKILL so any Claude Code agent executes it the same way. - Pre-flight reuses lint-engine via subprocess (no new bridge module). Plan collapses from 11 tasks to 5: scaffold lib + SKILL + push-component additive + README/marketplace + PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-10-adhd-pull-component.md | 2148 +++++------------ .../specs/2026-05-10-adhd-pull-component.md | 348 ++- 2 files changed, 718 insertions(+), 1778 deletions(-) diff --git a/docs/superpowers/plans/2026-05-10-adhd-pull-component.md b/docs/superpowers/plans/2026-05-10-adhd-pull-component.md index 9a924ab..4767b56 100644 --- a/docs/superpowers/plans/2026-05-10-adhd-pull-component.md +++ b/docs/superpowers/plans/2026-05-10-adhd-pull-component.md @@ -4,9 +4,9 @@ **Goal:** Implement `/adhd:pull-component` — pulls a Figma Component Set back into a React source file, updating only design-token lookup tables and union type members; function body and JSX never modified. -**Architecture:** Zero-deps Node library at `plugins/adhd/lib/pull-component/`, mirroring the shape of `lib/push-component/`. Single skill at `plugins/adhd/skills/pull-component/SKILL.md` orchestrating an 11-phase flow. The React file is its own snapshot — no external state stored. Mapping (component path → Figma URL) lives in `adhd.config.ts` under `components..figma.url`, written by push on first push and by pull on first scaffold. Pre-flight reuses `lint-engine`'s `checkStructure` + variable-categorizer; class-resolver re-exports lint-engine's theme-parser + variable-categorizer to enforce one canonical Tailwind-to-design-token resolution. +**Architecture:** This skill runs inside Claude Code. The LLM is the diff/apply engine — it reads the React source, the Figma extract, computes the diff in working memory, prompts the user via `AskUserQuestion`, and applies changes via `Edit` tool calls. Traditional library code is reserved for the deterministic, testable surface: `config-writer.js` (mutates `adhd.config.ts` to add/read component mappings) and the existing `lint-engine` (reused for pre-flight via subprocess). The SKILL prompt is detailed enough that any Claude Code agent executes it the same way — every invariant (function body untouched, off-system comment format, abort conditions) is stated explicitly. -**Tech Stack:** Node 20 (lib runs zero-deps), TypeScript Compiler API (transitive dep via Next.js for parse-react.js), `node --test` runner, Figma MCP `use_figma`/`generate_figma_design` invoked from the SKILL only. +**Tech Stack:** Node 20 (lib runs zero-deps), TS compiler API for `config-writer.js` (already a transitive dep), `node --test` runner, Figma MCP `use_figma` invoked from the SKILL. --- @@ -16,1335 +16,37 @@ | File | Responsibility | |---|---| -| `parse-react.js` | TS compiler API walker; extract unions, props interface, lookup tables, function-body bounds | -| `class-resolver.js` | Re-exports lint-engine theme-parser + variable-categorizer; tokenizes multi-class strings; resolves each to design-token tuple | -| `differ.js` | Pure: `(localExtract, figmaExtract) → diff.json` | -| `apply.js` | Pure: `(sourceText, resolutions) → newSourceText`; preserves whitespace/comments/line endings | -| `config-writer.js` | Add/read `components..figma.url` in `adhd.config.ts`; idempotent | -| `cli.js` | Subcommands: `parse`, `extract`, `diff`, `apply`, `config-write` | +| `config-writer.js` | Read & idempotently add `components..figma.url` in `adhd.config.ts`; also `reverseLookupPath(source, figmaUrl)` | +| `cli.js` | Subcommands: `config-write`, `config-read`, `config-reverse` (deterministic schema ops only — everything else lives in the SKILL) | | `README.md` | One-paragraph module readme | -| `__tests__/parse-react.test.js` | Avatar fixture extraction tests | -| `__tests__/class-resolver.test.js` | Tailwind resolution tests | -| `__tests__/differ.test.js` | Diff shape tests | -| `__tests__/apply.test.js` | Source rewrite tests | -| `__tests__/config-writer.test.js` | Config update tests | -| `__tests__/cli.test.js` | Subcommand surface tests | -| `__fixtures__/badge-base.tsx` | Minimal synthetic component (Badge with 2 sizes + 2 variants) for fast unit tests | -| `__fixtures__/badge-figma-clean.json` | Figma extract matching `badge-base.tsx` | -| `__fixtures__/badge-figma-cell-change.json` | 1 cell differs | -| `__fixtures__/badge-figma-added-variant.json` | Figma has new variant value | -| `__fixtures__/badge-figma-removed-variant.json` | Figma missing a variant value | -| `__fixtures__/badge-figma-unbound.json` | Figma has unbound raw values | -| `__fixtures__/badge-after-cell-change.tsx` | Golden output after applying cell change | -| `__fixtures__/badge-after-added-variant.tsx` | Golden output after adding variant | -| `__fixtures__/badge-after-removed-variant.tsx` | Golden output after removing variant | -| `__fixtures__/badge-after-unbound-allowed.tsx` | Golden output after `--allow-unbound` confirm | +| `__tests__/config-writer.test.js` | Unit tests for the three pure functions | +| `__tests__/cli.test.js` | CLI surface tests | **New skill — `plugins/adhd/skills/pull-component/SKILL.md`:** -The 11-phase orchestrator, `disable-model-invocation: true`. +The 11-phase orchestrator, `disable-model-invocation: true`. This is the "intelligence" layer: extracts Figma via `use_figma`, reads the React source via `Read`, computes the diff in-context, prompts via `AskUserQuestion`, applies via `Edit`. **Modified files:** -- `plugins/adhd/skills/push-component/SKILL.md` — insert mapping-write step between Phase 11 finalize and Phase 12 report +- `plugins/adhd/skills/push-component/SKILL.md` — insert mapping-write step between Phase 11 (finalize) and Phase 12 (report) - `.claude-plugin/marketplace.json` — bump description to list 6 commands -- `README.md` — add pull-component row to command table; add scoped subsection -- `.github/workflows/ci.yml` — add `--test plugins/adhd/lib/pull-component/__tests__/` +- `README.md` — add pull-component row to command table; add "Pull a component" subsection +- `.github/workflows/ci.yml` — add `node --test plugins/adhd/lib/pull-component/__tests__/` ---- - -## Task 1: Scaffold library, CI step, and the synthetic Badge fixture - -**Files:** -- Create: `plugins/adhd/lib/pull-component/cli.js` (stub) -- Create: `plugins/adhd/lib/pull-component/README.md` -- Create: `plugins/adhd/lib/pull-component/__tests__/cli.test.js` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-base.tsx` -- Modify: `.github/workflows/ci.yml` - -- [ ] **Step 1: Write the failing test for cli `--help`** - -`plugins/adhd/lib/pull-component/__tests__/cli.test.js`: - -```javascript -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { spawnSync } = require('node:child_process'); -const path = require('node:path'); - -const CLI = path.resolve(__dirname, '..', 'cli.js'); - -test('cli with --help prints subcommand usage and exits 0', () => { - const result = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' }); - assert.equal(result.status, 0); - assert.match(result.stdout, /Usage:/); - assert.match(result.stdout, /parse/); - assert.match(result.stdout, /extract/); - assert.match(result.stdout, /diff/); - assert.match(result.stdout, /apply/); - assert.match(result.stdout, /config-write/); -}); - -test('cli with no args exits 2 with usage', () => { - const result = spawnSync('node', [CLI], { encoding: 'utf8' }); - assert.equal(result.status, 2); -}); - -test('cli with unknown subcommand exits 2', () => { - const result = spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }); - assert.equal(result.status, 2); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/cli.test.js` -Expected: FAIL — `cli.js` does not exist. - -- [ ] **Step 3: Implement the cli stub** - -`plugins/adhd/lib/pull-component/cli.js`: - -```javascript -#!/usr/bin/env node -'use strict'; - -function parseArgs(argv) { - const args = { _: [] }; - for (let i = 2; i < argv.length; i++) { - const a = argv[i]; - if (a === '--help' || a === '-h') { args.help = true; continue; } - if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; } - else { args._.push(a); } - } - return args; -} - -function printUsage() { - console.log(`Usage: - cli.js parse --output - cli.js extract --output - cli.js diff --local --figma --output - cli.js apply --source --resolutions --output - cli.js config-write --config --path --figma-url `); -} - -function main() { - const args = parseArgs(process.argv); - if (args.help) { printUsage(); process.exit(0); } - if (args._.length === 0) { printUsage(); process.exit(2); } - const cmd = args._[0]; - // Subcommands wired in later tasks. Reject unknown to keep behavior strict. - console.error('Unknown subcommand: ' + cmd); - process.exit(2); -} - -main(); -``` - -- [ ] **Step 4: Add the synthetic Badge fixture** - -`plugins/adhd/lib/pull-component/__fixtures__/badge-base.tsx`: - -```tsx -export type BadgeSize = "sm" | "md" | "lg"; -export type BadgeTone = "neutral" | "danger"; - -export interface BadgeProps { - label: string; - size?: BadgeSize; - tone?: BadgeTone; -} - -export const BADGE_BOX: Record = { - sm: "px-2 py-0.5", - md: "px-3 py-1", - lg: "px-4 py-2", -}; - -export const BADGE_TEXT: Record = { - sm: "text-xs", - md: "text-sm", - lg: "text-base", -}; - -export const BADGE_TONE: Record = { - neutral: "bg-zinc-100 text-zinc-700", - danger: "bg-red-100 text-red-700", -}; - -export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { - // Function body — pull never modifies this region. - const box = BADGE_BOX[size]; - const text = BADGE_TEXT[size]; - const tonecls = BADGE_TONE[tone]; - return {label}; -} -``` - -- [ ] **Step 5: Add module README** - -`plugins/adhd/lib/pull-component/README.md`: - -```markdown -# lib/pull-component - -Engine modules for `/adhd:pull-component`. Reads a Figma Component Set and -reconciles it back into a React source file. Updates lookup tables and -union types only — never modifies the function body or JSX. - -Modules: -- `parse-react.js` — TS compiler API walker (extracts unions, props, lookup tables) -- `class-resolver.js` — wraps lint-engine's Tailwind-to-design-token resolution -- `differ.js` — pure: local + figma → diff -- `apply.js` — pure: source + resolutions → new source -- `config-writer.js` — manages `adhd.config.ts` component mappings -- `cli.js` — orchestrator with subcommands invoked by SKILL.md - -See `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` for the -authoritative spec. -``` - -- [ ] **Step 6: Add CI step** - -Modify `.github/workflows/ci.yml`. Locate the `lib-tests` job, add after the push-component test step: - -```yaml - - name: Run pull-component tests - run: node --test plugins/adhd/lib/pull-component/__tests__/ -``` - -- [ ] **Step 7: Run tests, verify pass** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/` -Expected: 3 cli tests PASS. - -- [ ] **Step 8: Commit** - -```bash -git add plugins/adhd/lib/pull-component .github/workflows/ci.yml -git commit -m "Scaffold lib/pull-component with cli stub + badge fixture" -``` - ---- - -## Task 2: parse-react.js — extract unions, props, lookup tables from a React file - -**Files:** -- Create: `plugins/adhd/lib/pull-component/parse-react.js` -- Create: `plugins/adhd/lib/pull-component/__tests__/parse-react.test.js` - -- [ ] **Step 1: Write the failing tests** - -`plugins/adhd/lib/pull-component/__tests__/parse-react.test.js`: - -```javascript -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { parseReactComponent } = require('../parse-react'); - -const BADGE = fs.readFileSync( - path.resolve(__dirname, '..', '__fixtures__', 'badge-base.tsx'), - 'utf8', -); - -test('extracts string literal unions', () => { - const result = parseReactComponent(BADGE); - assert.deepEqual(result.unions.BadgeSize, ['sm', 'md', 'lg']); - assert.deepEqual(result.unions.BadgeTone, ['neutral', 'danger']); -}); - -test('extracts props interface with union references', () => { - const result = parseReactComponent(BADGE); - assert.equal(result.componentName, 'Badge'); - assert.deepEqual(result.props.size, { unionRef: 'BadgeSize', optional: true }); - assert.deepEqual(result.props.tone, { unionRef: 'BadgeTone', optional: true }); - assert.deepEqual(result.props.label, { type: 'string', optional: false }); -}); - -test('extracts single-axis Record lookup tables', () => { - const result = parseReactComponent(BADGE); - assert.deepEqual(result.tables.BADGE_BOX, { - axis: 'BadgeSize', - nested: false, - entries: { sm: 'px-2 py-0.5', md: 'px-3 py-1', lg: 'px-4 py-2' }, - }); - assert.deepEqual(result.tables.BADGE_TONE, { - axis: 'BadgeTone', - nested: false, - entries: { neutral: 'bg-zinc-100 text-zinc-700', danger: 'bg-red-100 text-red-700' }, - }); -}); - -test('records function body bounds (start/end positions) and never visits inside', () => { - const result = parseReactComponent(BADGE); - // Body bounds must encompass the function body. Anything between - // result.functionBody.start and .end is OFF LIMITS for apply(). - assert.ok(result.functionBody.start > 0); - assert.ok(result.functionBody.end > result.functionBody.start); - // The string at those bounds should contain "return" (the JSX return) - assert.match(BADGE.slice(result.functionBody.start, result.functionBody.end), /return { - const SOURCE = ` -export type S = "a" | "b"; -export type T = "x" | "y"; -export interface FooProps { s?: S; t?: T; } -export const T2: Record> = { - a: { x: "p-1", y: "p-2" }, - b: { x: "p-3", y: "p-4" }, -}; -export function Foo({ s = "a", t = "x" }: FooProps) { return ; } -`; - const result = parseReactComponent(SOURCE); - assert.deepEqual(result.tables.T2, { - axis: 'S', - nested: true, - innerAxis: 'T', - entries: { a: { x: 'p-1', y: 'p-2' }, b: { x: 'p-3', y: 'p-4' } }, - }); -}); - -test('ignores tables with non-string value types', () => { - const SOURCE = ` -export type S = "a" | "b"; -export interface FooProps { s?: S; } -export const SIZE_PX: Record = { a: 1, b: 2 }; -export function Foo() { return ; } -`; - const result = parseReactComponent(SOURCE); - assert.equal(result.tables.SIZE_PX, undefined); -}); - -test('ignores tables defined inside a function body', () => { - const SOURCE = ` -export type S = "a" | "b"; -export interface FooProps { s?: S; } -export function Foo() { - const INLINE: Record = { a: "x", b: "y" }; - return ; -} -`; - const result = parseReactComponent(SOURCE); - assert.equal(result.tables.INLINE, undefined); -}); - -test('aborts on file with no exported function component', () => { - const SOURCE = `export const NOT_A_COMPONENT = 42;`; - assert.throws(() => parseReactComponent(SOURCE), /no exported function component/i); -}); -``` - -- [ ] **Step 2: Verify tests fail** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/parse-react.test.js` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement parse-react.js** - -`plugins/adhd/lib/pull-component/parse-react.js`: - -```javascript -'use strict'; - -const ts = require('typescript'); - -function parseReactComponent(source) { - const sourceFile = ts.createSourceFile('component.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - - const unions = {}; - const props = {}; - const tables = {}; - let componentName = null; - let propsInterfaceName = null; - let functionBody = null; - - // Pass 1: union aliases, props interface, function body bounds, function name. - for (const stmt of sourceFile.statements) { - if (ts.isTypeAliasDeclaration(stmt) && ts.isUnionTypeNode(stmt.type)) { - const members = []; - let allLiterals = true; - for (const member of stmt.type.types) { - if (ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal)) { - members.push(member.literal.text); - } else { - allLiterals = false; - break; - } - } - if (allLiterals) unions[stmt.name.text] = members; - } - if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && /Props$/.test(stmt.name.text)) { - propsInterfaceName = stmt.name.text; - const memberList = ts.isInterfaceDeclaration(stmt) ? stmt.members : (ts.isTypeLiteralNode(stmt.type) ? stmt.type.members : []); - for (const member of memberList) { - if (!ts.isPropertySignature(member) || !member.name) continue; - const propName = member.name.getText(sourceFile); - const optional = !!member.questionToken; - if (member.type && ts.isTypeReferenceNode(member.type)) { - const refName = member.type.typeName.getText(sourceFile); - if (unions[refName]) { - props[propName] = { unionRef: refName, optional }; - } else { - props[propName] = { type: refName, optional }; - } - } else if (member.type) { - const kind = member.type.kind; - if (kind === ts.SyntaxKind.StringKeyword) props[propName] = { type: 'string', optional }; - else if (kind === ts.SyntaxKind.NumberKeyword) props[propName] = { type: 'number', optional }; - else if (kind === ts.SyntaxKind.BooleanKeyword) props[propName] = { type: 'boolean', optional }; - else props[propName] = { type: 'unknown', optional }; - } - } - } - if (ts.isFunctionDeclaration(stmt) && stmt.modifiers && stmt.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword) && stmt.name && stmt.body) { - componentName = stmt.name.text; - functionBody = { start: stmt.body.getStart(sourceFile), end: stmt.body.getEnd() }; - } - } - - if (!componentName) { - throw new Error('No exported function component found in source'); - } - - // Pass 2: lookup tables. Only top-level VariableStatement with a Record annotation. - for (const stmt of sourceFile.statements) { - if (!ts.isVariableStatement(stmt)) continue; - for (const decl of stmt.declarationList.declarations) { - if (!decl.name || !ts.isIdentifier(decl.name)) continue; - const name = decl.name.text; - const annot = decl.type; - if (!annot || !ts.isTypeReferenceNode(annot)) continue; - if (annot.typeName.getText(sourceFile) !== 'Record') continue; - if (!annot.typeArguments || annot.typeArguments.length !== 2) continue; - const outer = annot.typeArguments[0]; - const inner = annot.typeArguments[1]; - const outerName = outer.getText(sourceFile); - if (!unions[outerName]) continue; - - const init = decl.initializer; - if (!init || !ts.isObjectLiteralExpression(init)) continue; - - // 1-axis: Record - if (inner.kind === ts.SyntaxKind.StringKeyword) { - const entries = {}; - for (const prop of init.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const key = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : (ts.isStringLiteral(prop.name) ? prop.name.text : null); - if (!key) continue; - if (!ts.isStringLiteral(prop.initializer)) continue; - entries[key] = prop.initializer.text; - } - tables[name] = { axis: outerName, nested: false, entries }; - continue; - } - - // 2-axis: Record> - if (ts.isTypeReferenceNode(inner) && inner.typeName.getText(sourceFile) === 'Record' && inner.typeArguments && inner.typeArguments.length === 2 && inner.typeArguments[1].kind === ts.SyntaxKind.StringKeyword) { - const innerName = inner.typeArguments[0].getText(sourceFile); - if (!unions[innerName]) continue; - const entries = {}; - for (const prop of init.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const outerKey = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : (ts.isStringLiteral(prop.name) ? prop.name.text : null); - if (!outerKey || !ts.isObjectLiteralExpression(prop.initializer)) continue; - entries[outerKey] = {}; - for (const inProp of prop.initializer.properties) { - if (!ts.isPropertyAssignment(inProp)) continue; - const innerKey = inProp.name && ts.isIdentifier(inProp.name) ? inProp.name.text : (ts.isStringLiteral(inProp.name) ? inProp.name.text : null); - if (!innerKey) continue; - if (!ts.isStringLiteral(inProp.initializer)) continue; - entries[outerKey][innerKey] = inProp.initializer.text; - } - } - tables[name] = { axis: outerName, nested: true, innerAxis: innerName, entries }; - } - } - } - - return { componentName, propsInterfaceName, unions, props, tables, functionBody }; -} - -module.exports = { parseReactComponent }; -``` - -- [ ] **Step 4: Verify tests pass** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/parse-react.test.js` -Expected: 8 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add plugins/adhd/lib/pull-component/parse-react.js plugins/adhd/lib/pull-component/__tests__/parse-react.test.js -git commit -m "parse-react: extract unions, props, lookup tables via TS compiler API" -``` - ---- - -## Task 3: class-resolver.js — wrap lint-engine for Tailwind-to-design-token resolution - -**Files:** -- Create: `plugins/adhd/lib/pull-component/class-resolver.js` -- Create: `plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js` - -- [ ] **Step 1: Write the failing tests** - -`plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js`: - -```javascript -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { resolveClassString, resolveClass } = require('../class-resolver'); - -const SAMPLE_GLOBALS_CSS = ` -@import "tailwindcss"; -@theme { - --color-zinc-100: oklch(0.967 0.001 286.375); - --color-red-100: oklch(0.936 0.032 17.717); - --color-red-700: oklch(0.444 0.177 26.899); - --color-zinc-700: oklch(0.37 0.013 285.805); - --spacing: 0.25rem; - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; -} -`; - -test('resolves a single utility class to a design-token tuple', () => { - const r = resolveClass('bg-red-100', SAMPLE_GLOBALS_CSS); - assert.equal(r.domain, 'color'); - assert.equal(r.path, 'red/100'); -}); - -test('returns null for an unknown utility', () => { - assert.equal(resolveClass('bg-not-a-color', SAMPLE_GLOBALS_CSS), null); -}); - -test('classifies layout-only tokens as ignored', () => { - assert.equal(resolveClass('flex', SAMPLE_GLOBALS_CSS), null); - assert.equal(resolveClass('items-center', SAMPLE_GLOBALS_CSS), null); -}); - -test('resolves a typography token', () => { - const r = resolveClass('text-xs', SAMPLE_GLOBALS_CSS); - assert.equal(r.domain, 'typography'); - assert.equal(r.path, 'text/xs'); -}); - -test('resolveClassString splits multi-class strings and returns per-token resolution', () => { - const r = resolveClassString('bg-red-100 text-red-700 flex items-center px-2', SAMPLE_GLOBALS_CSS); - // Returns an ARRAY of { token, resolved } entries - const byToken = Object.fromEntries(r.map(e => [e.token, e.resolved])); - assert.equal(byToken['bg-red-100'].domain, 'color'); - assert.equal(byToken['text-red-700'].domain, 'color'); - assert.equal(byToken['flex'], null); - assert.equal(byToken['items-center'], null); -}); - -test('preserves token order in resolveClassString output', () => { - const r = resolveClassString('px-2 py-1 bg-zinc-100', SAMPLE_GLOBALS_CSS); - assert.deepEqual(r.map(e => e.token), ['px-2', 'py-1', 'bg-zinc-100']); -}); - -test('arbitrary-value tokens (text-[10px], h-[80px]) return marker resolved: { domain: "arbitrary" }', () => { - const r = resolveClass('text-[10px]', SAMPLE_GLOBALS_CSS); - assert.equal(r && r.domain, 'arbitrary'); -}); -``` - -- [ ] **Step 2: Verify tests fail** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement class-resolver.js** - -`plugins/adhd/lib/pull-component/class-resolver.js`: - -```javascript -'use strict'; - -// Re-exports + wraps lint-engine's Tailwind-to-design-token resolution. -// This is the symmetric-pipeline assertion — pull and lint share one resolver. - -const { parseGlobalsCss } = require('../lint-engine/theme-parser'); -const { categorizeVariable } = require('../lint-engine/variable-categorizer'); - -// Layout-only token prefixes — never represent design tokens. -const LAYOUT_PREFIXES = [ - 'flex', 'grid', 'block', 'inline', 'hidden', 'absolute', 'relative', 'fixed', 'sticky', - 'items-', 'justify-', 'content-', 'self-', 'place-', 'order-', 'col-', 'row-', - 'overflow-', 'whitespace-', 'truncate', 'select-', 'cursor-', 'pointer-events-', - 'z-', 'opacity-', 'visible', 'invisible', 'isolate', - 'ring-offset-', 'outline-none', 'appearance-', -]; - -function isLayoutOnly(token) { - return LAYOUT_PREFIXES.some(p => token === p.replace(/-$/, '') || token.startsWith(p)); -} - -// "bg-red-100" → { utility: "bg", value: "red-100" } -// "text-xs" → { utility: "text", value: "xs" } -// "text-[10px]"→ { utility: "text", value: "[10px]" } -function parseToken(token) { - const m = /^(bg|text|border|fill|stroke|h|w|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|rounded)-(.+)$/.exec(token); - if (!m) return null; - return { utility: m[1], value: m[2] }; -} - -// Map Tailwind utility prefix → design-token domain. -const UTILITY_TO_DOMAIN = { - bg: 'color', text: 'typography-or-color', border: 'color', fill: 'color', stroke: 'color', - h: 'sizing', w: 'sizing', - p: 'spacing', px: 'spacing', py: 'spacing', pt: 'spacing', pb: 'spacing', pl: 'spacing', pr: 'spacing', - m: 'spacing', mx: 'spacing', my: 'spacing', mt: 'spacing', mb: 'spacing', ml: 'spacing', mr: 'spacing', - gap: 'spacing', rounded: 'radius', -}; - -function resolveClass(token, globalsCss) { - if (isLayoutOnly(token)) return null; - const parts = parseToken(token); - if (!parts) return null; - const { utility, value } = parts; - - // Arbitrary value (e.g. text-[10px], h-[80px]) — flagged with domain: "arbitrary". - if (value.startsWith('[') && value.endsWith(']')) { - return { domain: 'arbitrary', token, raw: value.slice(1, -1) }; - } - - const theme = parseGlobalsCss(globalsCss); - - if (utility === 'text') { - // text-xs / text-sm / text-base → typography variable - if (theme && theme.typography && theme.typography['text/' + value] !== undefined) { - return { domain: 'typography', path: 'text/' + value }; - } - // text-red-700 → color variable - if (theme && theme.color && theme.color[value.replace(/-/g, '/')] !== undefined) { - return { domain: 'color', path: value.replace(/-/g, '/') }; - } - return null; - } - - if (utility === 'bg' || utility === 'border' || utility === 'fill' || utility === 'stroke') { - const path = value.replace(/-/g, '/'); - if (theme && theme.color && theme.color[path] !== undefined) { - return { domain: 'color', path }; - } - return null; - } - - if (utility === 'rounded') { - if (theme && theme.radius && theme.radius[value] !== undefined) { - return { domain: 'radius', path: value }; - } - return null; - } - - // Sizing & spacing: Tailwind v4 uses a multiplier — `h-6` means 6 * --spacing. - // For the diff, we just record the utility token; categorizeVariable does the - // actual var-resolution. v1 records the resolved px value when possible. - if (UTILITY_TO_DOMAIN[utility] === 'spacing' || UTILITY_TO_DOMAIN[utility] === 'sizing') { - return { domain: UTILITY_TO_DOMAIN[utility], path: utility + '/' + value }; - } - - return null; -} - -function resolveClassString(classString, globalsCss) { - const tokens = (classString || '').split(/\s+/).filter(Boolean); - return tokens.map(token => ({ token, resolved: resolveClass(token, globalsCss) })); -} - -module.exports = { resolveClass, resolveClassString }; -``` - -- [ ] **Step 4: Verify tests pass** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js` -Expected: 7 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add plugins/adhd/lib/pull-component/class-resolver.js plugins/adhd/lib/pull-component/__tests__/class-resolver.test.js -git commit -m "class-resolver: wrap lint-engine theme-parser for class-to-token resolution" -``` - ---- - -## Task 4: differ.js — pure function for local vs Figma diff - -**Files:** -- Create: `plugins/adhd/lib/pull-component/differ.js` -- Create: `plugins/adhd/lib/pull-component/__tests__/differ.test.js` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-clean.json` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-cell-change.json` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-added-variant.json` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-figma-removed-variant.json` - -- [ ] **Step 1: Write the four Figma fixture files** - -The Figma extract shape mirrors what the SKILL produces by serializing a Component Set. Each variant has resolved design tokens per relevant property; pull does NOT need the full Figma tree, only the per-variant per-property bound values. - -`badge-figma-clean.json`: - -```json -{ - "componentSetId": "100:1", - "componentName": "Badge", - "variantAxes": { - "size": ["sm", "md", "lg"], - "tone": ["neutral", "danger"] - }, - "variants": [ - { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "sm", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "md", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "lg", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } } - ] -} -``` - -`badge-figma-cell-change.json`: same as clean except BADGE_TEXT.md is `text-base` (changed from `text-sm`): - -```json -{ - "componentSetId": "100:1", - "componentName": "Badge", - "variantAxes": { - "size": ["sm", "md", "lg"], - "tone": ["neutral", "danger"] - }, - "variants": [ - { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "sm", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "md", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "lg", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } } - ] -} -``` - -`badge-figma-added-variant.json`: clean plus a new size=xl variant: - -```json -{ - "componentSetId": "100:1", - "componentName": "Badge", - "variantAxes": { - "size": ["sm", "md", "lg", "xl"], - "tone": ["neutral", "danger"] - }, - "variants": [ - { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "xl", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-5 py-3", "BADGE_TEXT": "text-lg", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "sm", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "md", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "lg", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-red-100 text-red-700" } }, - { "props": { "size": "xl", "tone": "danger" }, "tokens": { "BADGE_BOX": "px-5 py-3", "BADGE_TEXT": "text-lg", "BADGE_TONE": "bg-red-100 text-red-700" } } - ] -} -``` - -`badge-figma-removed-variant.json`: clean minus all `tone=danger` variants: - -```json -{ - "componentSetId": "100:1", - "componentName": "Badge", - "variantAxes": { - "size": ["sm", "md", "lg"], - "tone": ["neutral"] - }, - "variants": [ - { "props": { "size": "sm", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-2 py-0.5", "BADGE_TEXT": "text-xs", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "md", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-3 py-1", "BADGE_TEXT": "text-sm", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } }, - { "props": { "size": "lg", "tone": "neutral" }, "tokens": { "BADGE_BOX": "px-4 py-2", "BADGE_TEXT": "text-base", "BADGE_TONE": "bg-zinc-100 text-zinc-700" } } - ] -} -``` - -- [ ] **Step 2: Write the failing tests** - -`plugins/adhd/lib/pull-component/__tests__/differ.test.js`: - -```javascript -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { parseReactComponent } = require('../parse-react'); -const { diffLocalVsFigma } = require('../differ'); - -const FX = (n) => path.resolve(__dirname, '..', '__fixtures__', n); -const BADGE = fs.readFileSync(FX('badge-base.tsx'), 'utf8'); - -function loadFigma(name) { - return JSON.parse(fs.readFileSync(FX(name), 'utf8')); -} - -test('clean figma produces empty diff', () => { - const local = parseReactComponent(BADGE); - const figma = loadFigma('badge-figma-clean.json'); - const diff = diffLocalVsFigma(local, figma); - assert.deepEqual(diff.unionDiff, []); - assert.deepEqual(diff.tableDiff, []); - assert.deepEqual(diff.unmapped, []); -}); - -test('one cell change shows up in tableDiff', () => { - const local = parseReactComponent(BADGE); - const figma = loadFigma('badge-figma-cell-change.json'); - const diff = diffLocalVsFigma(local, figma); - assert.equal(diff.tableDiff.length, 1); - const t = diff.tableDiff[0]; - assert.equal(t.table, 'BADGE_TEXT'); - assert.equal(t.axis, 'size'); - assert.equal(t.cells.length, 1); - assert.deepEqual(t.cells[0], { key: 'md', local: 'text-sm', figma: 'text-base' }); -}); - -test('figma added a variant value → unionDiff has add entry', () => { - const local = parseReactComponent(BADGE); - const figma = loadFigma('badge-figma-added-variant.json'); - const diff = diffLocalVsFigma(local, figma); - assert.equal(diff.unionDiff.length, 1); - assert.deepEqual(diff.unionDiff[0], { - union: 'BadgeSize', axis: 'size', add: ['xl'], remove: [], - }); -}); - -test('figma removed a variant value → unionDiff has remove entry', () => { - const local = parseReactComponent(BADGE); - const figma = loadFigma('badge-figma-removed-variant.json'); - const diff = diffLocalVsFigma(local, figma); - const tone = diff.unionDiff.find(d => d.axis === 'tone'); - assert.ok(tone); - assert.deepEqual(tone.remove, ['danger']); -}); - -test('figma has axis with no matching Record<...> → unmapped entry', () => { - const local = parseReactComponent(BADGE); - const figma = loadFigma('badge-figma-clean.json'); - // Synthesize an extra axis - figma.variantAxes.theme = ['light', 'dark']; - const diff = diffLocalVsFigma(local, figma); - const unmapped = diff.unmapped.find(u => u.figmaAxis === 'theme'); - assert.ok(unmapped); - assert.deepEqual(unmapped.values, ['light', 'dark']); -}); -``` - -- [ ] **Step 3: Verify tests fail** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/differ.test.js` -Expected: FAIL — module not found. - -- [ ] **Step 4: Implement differ.js** - -`plugins/adhd/lib/pull-component/differ.js`: - -```javascript -'use strict'; - -// Pure function: (parseReactComponent output, figma extract) → diff -// Diff shape (see spec section "Build the diff"): -// { unionDiff: [...], tableDiff: [...], unmapped: [...] } - -function diffLocalVsFigma(local, figma) { - const unionDiff = []; - const tableDiff = []; - const unmapped = []; - - // Build axis → union name lookup from props (e.g. props.size = { unionRef: "BadgeSize" }) - const axisToUnion = {}; - for (const [propName, propDef] of Object.entries(local.props || {})) { - if (propDef.unionRef) axisToUnion[propName] = propDef.unionRef; - } - - // --- Union diff: per axis, compare local union members vs figma variantAxes. - for (const [axis, figmaMembers] of Object.entries(figma.variantAxes || {})) { - const unionName = axisToUnion[axis]; - if (!unionName) { - // Figma has an axis but local has no matching prop/union → unmapped. - unmapped.push({ figmaAxis: axis, values: [...figmaMembers], reason: 'no matching prop/union' }); - continue; - } - const localMembers = local.unions[unionName] || []; - const add = figmaMembers.filter(v => !localMembers.includes(v)); - const remove = localMembers.filter(v => !figmaMembers.includes(v)); - if (add.length || remove.length) { - unionDiff.push({ union: unionName, axis, add, remove }); - } - } - - // --- Table diff: for each local table, walk figma variants whose props match the table's axis keys. - for (const [tableName, table] of Object.entries(local.tables || {})) { - const axisName = Object.entries(axisToUnion).find(([, u]) => u === table.axis)?.[0]; - if (!axisName) continue; // axis not in props → can't reverse-resolve - - if (!table.nested) { - const cells = []; - // For each key in local table, find the figma value(s) for variants where props[axisName] === key. - // If figma values differ within the group, take the first (consistent across non-axis dims is the convention). - for (const key of Object.keys(table.entries)) { - const matching = (figma.variants || []).filter(v => v.props && v.props[axisName] === key); - if (matching.length === 0) continue; // figma doesn't have this variant (handled by unionDiff) - const figmaValue = matching[0].tokens && matching[0].tokens[tableName]; - if (figmaValue === undefined) continue; // figma extract didn't include this token - const localValue = table.entries[key]; - if (localValue !== figmaValue) { - cells.push({ key, local: localValue, figma: figmaValue }); - } - } - if (cells.length) { - tableDiff.push({ table: tableName, axis: axisName, cells }); - } - } else { - // 2-axis: outerKey + innerKey - const innerAxisName = Object.entries(axisToUnion).find(([, u]) => u === table.innerAxis)?.[0]; - if (!innerAxisName) continue; - const cells = []; - for (const [outerKey, inner] of Object.entries(table.entries)) { - for (const [innerKey, localValue] of Object.entries(inner)) { - const matching = (figma.variants || []).filter(v => - v.props && v.props[axisName] === outerKey && v.props[innerAxisName] === innerKey, - ); - if (matching.length === 0) continue; - const figmaValue = matching[0].tokens && matching[0].tokens[tableName]; - if (figmaValue === undefined) continue; - if (localValue !== figmaValue) { - cells.push({ key: `${outerKey}.${innerKey}`, outerKey, innerKey, local: localValue, figma: figmaValue }); - } - } - } - if (cells.length) { - tableDiff.push({ table: tableName, axis: axisName, innerAxis: innerAxisName, cells }); - } - } - } - - return { unionDiff, tableDiff, unmapped }; -} - -module.exports = { diffLocalVsFigma }; -``` - -- [ ] **Step 5: Verify tests pass** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/differ.test.js` -Expected: 5 tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add plugins/adhd/lib/pull-component/differ.js plugins/adhd/lib/pull-component/__tests__/differ.test.js plugins/adhd/lib/pull-component/__fixtures__/badge-figma-*.json -git commit -m "differ: pure function comparing local extract to figma variants" -``` - ---- - -## Task 5: apply.js — AST-aware source rewrite - -**Files:** -- Create: `plugins/adhd/lib/pull-component/apply.js` -- Create: `plugins/adhd/lib/pull-component/__tests__/apply.test.js` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-after-cell-change.tsx` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-after-added-variant.tsx` -- Create: `plugins/adhd/lib/pull-component/__fixtures__/badge-after-removed-variant.tsx` - -- [ ] **Step 1: Write the golden output fixtures** - -`badge-after-cell-change.tsx` — same as `badge-base.tsx` except BADGE_TEXT.md changed from `"text-sm"` to `"text-base"`. Preserve all surrounding whitespace and comments. - -```tsx -export type BadgeSize = "sm" | "md" | "lg"; -export type BadgeTone = "neutral" | "danger"; - -export interface BadgeProps { - label: string; - size?: BadgeSize; - tone?: BadgeTone; -} - -export const BADGE_BOX: Record = { - sm: "px-2 py-0.5", - md: "px-3 py-1", - lg: "px-4 py-2", -}; - -export const BADGE_TEXT: Record = { - sm: "text-xs", - md: "text-base", - lg: "text-base", -}; - -export const BADGE_TONE: Record = { - neutral: "bg-zinc-100 text-zinc-700", - danger: "bg-red-100 text-red-700", -}; - -export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { - // Function body — pull never modifies this region. - const box = BADGE_BOX[size]; - const text = BADGE_TEXT[size]; - const tonecls = BADGE_TONE[tone]; - return {label}; -} -``` - -`badge-after-added-variant.tsx` — adds `xl` to BadgeSize union and a new `xl` entry in each `Record` table: - -```tsx -export type BadgeSize = "sm" | "md" | "lg" | "xl"; -export type BadgeTone = "neutral" | "danger"; - -export interface BadgeProps { - label: string; - size?: BadgeSize; - tone?: BadgeTone; -} - -export const BADGE_BOX: Record = { - sm: "px-2 py-0.5", - md: "px-3 py-1", - lg: "px-4 py-2", - xl: "px-5 py-3", -}; - -export const BADGE_TEXT: Record = { - sm: "text-xs", - md: "text-sm", - lg: "text-base", - xl: "text-lg", -}; - -export const BADGE_TONE: Record = { - neutral: "bg-zinc-100 text-zinc-700", - danger: "bg-red-100 text-red-700", -}; - -export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { - // Function body — pull never modifies this region. - const box = BADGE_BOX[size]; - const text = BADGE_TEXT[size]; - const tonecls = BADGE_TONE[tone]; - return {label}; -} -``` - -`badge-after-removed-variant.tsx` — removes `danger` from BadgeTone and from BADGE_TONE: - -```tsx -export type BadgeSize = "sm" | "md" | "lg"; -export type BadgeTone = "neutral"; - -export interface BadgeProps { - label: string; - size?: BadgeSize; - tone?: BadgeTone; -} - -export const BADGE_BOX: Record = { - sm: "px-2 py-0.5", - md: "px-3 py-1", - lg: "px-4 py-2", -}; - -export const BADGE_TEXT: Record = { - sm: "text-xs", - md: "text-sm", - lg: "text-base", -}; - -export const BADGE_TONE: Record = { - neutral: "bg-zinc-100 text-zinc-700", -}; - -export function Badge({ label, size = "md", tone = "neutral" }: BadgeProps) { - // Function body — pull never modifies this region. - const box = BADGE_BOX[size]; - const text = BADGE_TEXT[size]; - const tonecls = BADGE_TONE[tone]; - return {label}; -} -``` - -- [ ] **Step 2: Write the failing tests** - -`plugins/adhd/lib/pull-component/__tests__/apply.test.js`: - -```javascript -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { applyResolutions } = require('../apply'); - -const FX = (n) => path.resolve(__dirname, '..', '__fixtures__', n); -const BADGE = fs.readFileSync(FX('badge-base.tsx'), 'utf8'); - -test('empty resolutions returns byte-identical source', () => { - const out = applyResolutions(BADGE, { unions: {}, tables: {} }); - assert.equal(out, BADGE); -}); - -test('single cell update preserves surrounding whitespace and other entries', () => { - const resolutions = { unions: {}, tables: { BADGE_TEXT: { md: 'text-base' } } }; - const out = applyResolutions(BADGE, resolutions); - const expected = fs.readFileSync(FX('badge-after-cell-change.tsx'), 'utf8'); - assert.equal(out, expected); -}); - -test('adding a union value appends to union and adds entry to every Record table', () => { - const resolutions = { - unions: { BadgeSize: { add: ['xl'], remove: [] } }, - tables: { - BADGE_BOX: { xl: 'px-5 py-3' }, - BADGE_TEXT: { xl: 'text-lg' }, - }, - }; - const out = applyResolutions(BADGE, resolutions); - const expected = fs.readFileSync(FX('badge-after-added-variant.tsx'), 'utf8'); - assert.equal(out, expected); -}); - -test('removing a union value strips it from union and from every Record table', () => { - const resolutions = { - unions: { BadgeTone: { add: [], remove: ['danger'] } }, - tables: {}, - }; - const out = applyResolutions(BADGE, resolutions); - const expected = fs.readFileSync(FX('badge-after-removed-variant.tsx'), 'utf8'); - assert.equal(out, expected); -}); - -test('preserves CRLF line endings if input has them', () => { - const crlfSource = BADGE.replace(/\n/g, '\r\n'); - const out = applyResolutions(crlfSource, { unions: {}, tables: { BADGE_TEXT: { md: 'text-base' } } }); - assert.ok(out.includes('\r\n')); - assert.ok(!out.match(/[^\r]\n/)); -}); - -test('does not modify text inside the function body region', () => { - const sourceWithBodyHook = BADGE.replace( - 'const box = BADGE_BOX[size];', - 'const box = BADGE_BOX[size]; // hand-written', - ); - const out = applyResolutions(sourceWithBodyHook, { unions: {}, tables: { BADGE_TEXT: { md: 'text-base' } } }); - assert.match(out, /BADGE_BOX\[size\]; \/\/ hand-written/); -}); -``` - -- [ ] **Step 3: Verify tests fail** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/apply.test.js` -Expected: FAIL — module not found. - -- [ ] **Step 4: Implement apply.js** - -`plugins/adhd/lib/pull-component/apply.js`: - -```javascript -'use strict'; - -const ts = require('typescript'); -const { parseReactComponent } = require('./parse-react'); - -// Pure function: source text + resolutions → new source text. -// Strategy: -// 1. Re-parse the source to get AST node positions for: unions and tables. -// 2. Compute edits as { start, end, newText }, ordered by descending start. -// 3. Apply edits to a single mutable string, splicing in reverse order so -// earlier positions don't shift later ones. -// Function body bounds from parseReactComponent are NEVER referenced — we only -// touch the union type alias declarations and the lookup-table object literals. - -function applyResolutions(source, resolutions) { - const sourceFile = ts.createSourceFile('component.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - const local = parseReactComponent(source); - const edits = []; - - // 1. Union edits. - for (const [unionName, change] of Object.entries(resolutions.unions || {})) { - if (!change || ((!change.add || change.add.length === 0) && (!change.remove || change.remove.length === 0))) continue; - const unionStmt = sourceFile.statements.find(s => - ts.isTypeAliasDeclaration(s) && s.name.text === unionName, - ); - if (!unionStmt || !ts.isUnionTypeNode(unionStmt.type)) continue; - const currentMembers = local.unions[unionName] || []; - const removeSet = new Set(change.remove || []); - const updated = currentMembers.filter(m => !removeSet.has(m)).concat((change.add || []).filter(m => !currentMembers.includes(m))); - const newUnionText = updated.map(m => `"${m}"`).join(' | '); - edits.push({ - start: unionStmt.type.getStart(sourceFile), - end: unionStmt.type.getEnd(), - newText: newUnionText, - }); - } - - // Build a map: unionName → list of (tableName, table, varStmt, init) - const tablesByUnion = {}; - for (const stmt of sourceFile.statements) { - if (!ts.isVariableStatement(stmt)) continue; - for (const decl of stmt.declarationList.declarations) { - if (!decl.name || !ts.isIdentifier(decl.name)) continue; - const name = decl.name.text; - if (!local.tables[name]) continue; - if (!decl.initializer || !ts.isObjectLiteralExpression(decl.initializer)) continue; - const axis = local.tables[name].axis; - (tablesByUnion[axis] ||= []).push({ name, stmt, decl, init: decl.initializer }); - } - } - - // 2. Cascade union add/remove into every table whose axis matches the union. - for (const [unionName, change] of Object.entries(resolutions.unions || {})) { - const targets = tablesByUnion[unionName] || []; - for (const t of targets) { - // Removal: drop properties whose key is in `remove`. - if (change.remove && change.remove.length > 0) { - const removeSet = new Set(change.remove); - for (const prop of t.init.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const keyName = prop.name && (ts.isIdentifier(prop.name) ? prop.name.text : (ts.isStringLiteral(prop.name) ? prop.name.text : null)); - if (keyName && removeSet.has(keyName)) { - // Edit deletes the entire property + its trailing comma + leading newline/whitespace. - const start = findLineStart(source, prop.getStart(sourceFile)); - const end = findEndOfPropertyLine(source, prop.getEnd()); - edits.push({ start, end, newText: '' }); - } - } - } - // Addition: append a property at the end of the object literal. - if (change.add && change.add.length > 0 && resolutions.tables && resolutions.tables[t.name]) { - for (const newKey of change.add) { - const newValue = resolutions.tables[t.name][newKey]; - if (newValue === undefined) continue; - // Insertion point: just before the closing brace of the object literal. - const closeBrace = t.init.getEnd() - 1; // the `}` itself - // Detect indentation from the first existing property (if any). - let indent = ' '; - if (t.init.properties.length > 0) { - const firstPropStart = t.init.properties[0].getStart(sourceFile); - const lineStart = findLineStart(source, firstPropStart); - indent = source.slice(lineStart, firstPropStart); - } - // If the off-system marker is needed, the resolutions.tables value should include it as a comment prefix. - // For simplicity here, resolutions.tables values are plain strings; the SKILL preprocesses unbound - // entries by setting resolutions.tables[name][key] to include the comment + newline. - const newProp = `${indent}${newKey}: "${newValue}",\n`; - edits.push({ start: closeBrace, end: closeBrace, newText: newProp }); - } - } - } - } - - // 3. Cell-only updates: change property values where resolutions.tables specifies a key NOT covered by union add. - for (const [tableName, cells] of Object.entries(resolutions.tables || {})) { - const t = (Object.values(tablesByUnion).flat()).find(x => x.name === tableName); - if (!t) continue; - const axisUnion = local.tables[tableName].axis; - const addedSet = new Set((resolutions.unions && resolutions.unions[axisUnion] && resolutions.unions[axisUnion].add) || []); - for (const [key, newValue] of Object.entries(cells)) { - if (addedSet.has(key)) continue; // already handled by addition path above - // 2-axis table: key has form "outerKey.innerKey" - if (local.tables[tableName].nested && key.includes('.')) { - const [outerKey, innerKey] = key.split('.'); - const outerProp = t.init.properties.find(p => - ts.isPropertyAssignment(p) && p.name && ((ts.isIdentifier(p.name) && p.name.text === outerKey) || (ts.isStringLiteral(p.name) && p.name.text === outerKey)), - ); - if (!outerProp || !ts.isObjectLiteralExpression(outerProp.initializer)) continue; - const innerProp = outerProp.initializer.properties.find(p => - ts.isPropertyAssignment(p) && p.name && ((ts.isIdentifier(p.name) && p.name.text === innerKey) || (ts.isStringLiteral(p.name) && p.name.text === innerKey)), - ); - if (!innerProp || !ts.isStringLiteral(innerProp.initializer)) continue; - edits.push({ - start: innerProp.initializer.getStart(sourceFile), - end: innerProp.initializer.getEnd(), - newText: `"${newValue}"`, - }); - continue; - } - // 1-axis - const prop = t.init.properties.find(p => - ts.isPropertyAssignment(p) && p.name && ((ts.isIdentifier(p.name) && p.name.text === key) || (ts.isStringLiteral(p.name) && p.name.text === key)), - ); - if (!prop || !ts.isStringLiteral(prop.initializer)) continue; - edits.push({ - start: prop.initializer.getStart(sourceFile), - end: prop.initializer.getEnd(), - newText: `"${newValue}"`, - }); - } - } - - // Apply edits in reverse position order. - edits.sort((a, b) => b.start - a.start); - let out = source; - for (const e of edits) { - out = out.slice(0, e.start) + e.newText + out.slice(e.end); - } - return out; -} - -function findLineStart(source, position) { - let i = position; - while (i > 0 && source[i - 1] !== '\n') i--; - return i; -} - -function findEndOfPropertyLine(source, position) { - // Move past trailing comma and any whitespace through the newline. - let i = position; - if (source[i] === ',') i++; - while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i++; - if (source[i] === '\r') i++; - if (source[i] === '\n') i++; - return i; -} - -module.exports = { applyResolutions }; -``` - -- [ ] **Step 5: Verify tests pass** - -Run: `node --test plugins/adhd/lib/pull-component/__tests__/apply.test.js` -Expected: 6 tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add plugins/adhd/lib/pull-component/apply.js plugins/adhd/lib/pull-component/__tests__/apply.test.js plugins/adhd/lib/pull-component/__fixtures__/badge-after-*.tsx -git commit -m "apply: AST-aware source rewrite scoped to unions + lookup tables" -``` +**Out-of-bounds (do NOT create):** +- `parse-react.js` / `differ.js` / `apply.js` / `class-resolver.js` — these would be brittle pattern-matching reimplementations of work the LLM already does well. The SKILL handles these via Read + reasoning + Edit. --- -## Task 6: config-writer.js — read and write component mappings in adhd.config.ts +## Task 1: Scaffold lib + CI + config-writer **Files:** +- Create: `plugins/adhd/lib/pull-component/cli.js` - Create: `plugins/adhd/lib/pull-component/config-writer.js` +- Create: `plugins/adhd/lib/pull-component/README.md` +- Create: `plugins/adhd/lib/pull-component/__tests__/cli.test.js` - Create: `plugins/adhd/lib/pull-component/__tests__/config-writer.test.js` +- Modify: `.github/workflows/ci.yml` -- [ ] **Step 1: Write the failing tests** +- [ ] **Step 1: Write failing tests for `config-writer.js`** `plugins/adhd/lib/pull-component/__tests__/config-writer.test.js`: @@ -1353,7 +55,7 @@ git commit -m "apply: AST-aware source rewrite scoped to unions + lookup tables" const test = require('node:test'); const assert = require('node:assert/strict'); -const { readComponentMapping, addComponentMapping } = require('../config-writer'); +const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('../config-writer'); const MINIMAL_CONFIG = `const config = { figma: { url: "https://figma.com/design/ABC/" }, @@ -1375,13 +77,16 @@ export default config; `; test('readComponentMapping returns null when no components field exists', () => { - const result = readComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx'); - assert.equal(result, null); + assert.equal(readComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx'), null); }); test('readComponentMapping returns entry when path matches', () => { - const result = readComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx'); - assert.equal(result && result.figma.url, 'https://figma.com/design/ABC/?node-id=91-18'); + const r = readComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx'); + assert.equal(r && r.figma.url, 'https://figma.com/design/ABC/?node-id=91-18'); +}); + +test('readComponentMapping returns null for an absent path even if components exists', () => { + assert.equal(readComponentMapping(WITH_COMPONENTS, 'app/components/nope.tsx'), null); }); test('addComponentMapping creates components field if missing', () => { @@ -1410,18 +115,21 @@ test('addComponentMapping updates existing entry if URL differs', () => { }); test('reverseLookupPath finds the path for a given figma URL', () => { - const { reverseLookupPath } = require('../config-writer'); const path = reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=91-18'); assert.equal(path, 'app/components/avatar/index.tsx'); }); + +test('reverseLookupPath returns null for unknown URL', () => { + assert.equal(reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=999-1'), null); +}); ``` - [ ] **Step 2: Verify tests fail** Run: `node --test plugins/adhd/lib/pull-component/__tests__/config-writer.test.js` -Expected: FAIL. +Expected: FAIL — module not found. -- [ ] **Step 3: Implement config-writer.js** +- [ ] **Step 3: Implement `config-writer.js`** `plugins/adhd/lib/pull-component/config-writer.js`: @@ -1434,7 +142,6 @@ function parse(source) { return ts.createSourceFile('adhd.config.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); } -// Locate the object literal assigned to `const config = { ... }`. function findConfigObject(sourceFile) { for (const stmt of sourceFile.statements) { if (!ts.isVariableStatement(stmt)) continue; @@ -1464,7 +171,6 @@ function readComponentMapping(source, relPath) { ts.isPropertyAssignment(p) && p.name && ts.isStringLiteral(p.name) && p.name.text === relPath, ); if (!entry || !ts.isObjectLiteralExpression(entry.initializer)) return null; - const figma = findProperty(entry.initializer, 'figma'); if (!figma || !ts.isObjectLiteralExpression(figma.initializer)) return null; const url = findProperty(figma.initializer, 'url'); @@ -1505,7 +211,7 @@ function addComponentMapping(source, relPath, figmaUrl) { const cfg = findConfigObject(sf); if (!cfg) throw new Error('addComponentMapping: could not find `const config = { ... }`'); - // Case 1: existing components. with a different URL → replace its url inline. + // Case 1: existing components. with a different URL → replace url inline. const components = findProperty(cfg, 'components'); if (components && ts.isObjectLiteralExpression(components.initializer)) { const entry = components.initializer.properties.find(p => @@ -1522,7 +228,7 @@ function addComponentMapping(source, relPath, figmaUrl) { } } } - // Case 2: components exists but not this path → append a new entry before its closing brace. + // Case 2: components exists but not this path → append new entry before closing brace. const close = components.initializer.getEnd() - 1; const firstProp = components.initializer.properties[0]; let indent = ' '; @@ -1536,7 +242,6 @@ function addComponentMapping(source, relPath, figmaUrl) { // Case 3: no components field → insert one before the closing brace of `const config`. const close = cfg.getEnd() - 1; - // Detect the indentation used inside config (first existing property). const firstCfgProp = cfg.properties[0]; let baseIndent = ' '; if (firstCfgProp) { @@ -1550,94 +255,93 @@ function addComponentMapping(source, relPath, figmaUrl) { module.exports = { readComponentMapping, reverseLookupPath, addComponentMapping }; ``` -- [ ] **Step 4: Verify tests pass** +- [ ] **Step 4: Verify config-writer tests pass** Run: `node --test plugins/adhd/lib/pull-component/__tests__/config-writer.test.js` -Expected: 7 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add plugins/adhd/lib/pull-component/config-writer.js plugins/adhd/lib/pull-component/__tests__/config-writer.test.js -git commit -m "config-writer: idempotent add/read of components..figma.url" -``` +Expected: 9 tests PASS. ---- - -## Task 7: cli.js — wire subcommands - -**Files:** -- Modify: `plugins/adhd/lib/pull-component/cli.js` -- Modify: `plugins/adhd/lib/pull-component/__tests__/cli.test.js` +- [ ] **Step 5: Write failing tests for `cli.js`** -- [ ] **Step 1: Extend cli tests for each subcommand** - -Append to `plugins/adhd/lib/pull-component/__tests__/cli.test.js`: +`plugins/adhd/lib/pull-component/__tests__/cli.test.js`: ```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); const fs = require('node:fs'); const os = require('node:os'); +const CLI = path.resolve(__dirname, '..', 'cli.js'); + function tmp(filename, content) { const p = path.join(os.tmpdir(), 'adhd-pull-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '-' + filename); fs.writeFileSync(p, content); return p; } -const BADGE_PATH = path.resolve(__dirname, '..', '__fixtures__', 'badge-base.tsx'); - -test('parse subcommand writes a local.json manifest', () => { - const out = tmp('local.json', ''); - const r = spawnSync('node', [CLI, 'parse', BADGE_PATH, '--output', out], { encoding: 'utf8' }); - assert.equal(r.status, 0, r.stderr); - const m = JSON.parse(fs.readFileSync(out, 'utf8')); - assert.equal(m.componentName, 'Badge'); - assert.ok(m.unions.BadgeSize); - assert.ok(m.tables.BADGE_BOX); +test('cli with --help prints subcommand usage and exits 0', () => { + const r = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' }); + assert.equal(r.status, 0); + assert.match(r.stdout, /Usage:/); + assert.match(r.stdout, /config-write/); + assert.match(r.stdout, /config-read/); + assert.match(r.stdout, /config-reverse/); }); -test('diff subcommand writes a diff.json', () => { - // parse first - const local = tmp('local.json', ''); - spawnSync('node', [CLI, 'parse', BADGE_PATH, '--output', local], { encoding: 'utf8' }); - // figma fixture - const figma = path.resolve(__dirname, '..', '__fixtures__', 'badge-figma-cell-change.json'); - const out = tmp('diff.json', ''); - const r = spawnSync('node', [CLI, 'diff', '--local', local, '--figma', figma, '--output', out], { encoding: 'utf8' }); - assert.equal(r.status, 0, r.stderr); - const d = JSON.parse(fs.readFileSync(out, 'utf8')); - assert.equal(d.tableDiff.length, 1); +test('cli with no args exits 2', () => { + assert.equal(spawnSync('node', [CLI], { encoding: 'utf8' }).status, 2); }); -test('apply subcommand rewrites the source file via resolutions', () => { - const src = fs.readFileSync(BADGE_PATH, 'utf8'); - const srcPath = tmp('Badge.tsx', src); - const resolutions = tmp('res.json', JSON.stringify({ - unions: {}, - tables: { BADGE_TEXT: { md: 'text-base' } }, - })); - const out = tmp('out.tsx', ''); - const r = spawnSync('node', [CLI, 'apply', '--source', srcPath, '--resolutions', resolutions, '--output', out], { encoding: 'utf8' }); - assert.equal(r.status, 0, r.stderr); - const result = fs.readFileSync(out, 'utf8'); - assert.match(result, /md: "text-base"/); +test('cli with unknown subcommand exits 2', () => { + assert.equal(spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }).status, 2); }); -test('config-write subcommand adds a components entry', () => { +test('config-write subcommand adds a components entry to the config file', () => { const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); const r = spawnSync('node', [CLI, 'config-write', '--config', cfgPath, '--path', 'app/components/x.tsx', '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); const after = fs.readFileSync(cfgPath, 'utf8'); assert.match(after, /"app\/components\/x\.tsx":/); }); + +test('config-read subcommand prints the figma url to stdout', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n components: {\n "app/components/x.tsx": { figma: { url: "https://figma.com/design/ABC/?node-id=1-1" } },\n },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-read', '--config', cfgPath, '--path', 'app/components/x.tsx'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /node-id=1-1/); +}); + +test('config-read exits 1 with empty stdout when path is not mapped', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-read', '--config', cfgPath, '--path', 'app/components/missing.tsx'], { encoding: 'utf8' }); + assert.equal(r.status, 1); + assert.equal(r.stdout, ''); +}); + +test('config-reverse subcommand prints the path for a given URL', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n components: {\n "app/components/x.tsx": { figma: { url: "https://figma.com/design/ABC/?node-id=1-1" } },\n },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-reverse', '--config', cfgPath, '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /app\/components\/x\.tsx/); +}); + +test('config-reverse exits 1 with empty stdout when URL has no mapping', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-reverse', '--config', cfgPath, '--figma-url', 'https://figma.com/design/ABC/?node-id=9-9'], { encoding: 'utf8' }); + assert.equal(r.status, 1); + assert.equal(r.stdout, ''); +}); ``` -- [ ] **Step 2: Verify the new tests fail** +- [ ] **Step 6: Verify cli tests fail** Run: `node --test plugins/adhd/lib/pull-component/__tests__/cli.test.js` -Expected: 4 new subcommand tests FAIL; original 3 still pass. +Expected: FAIL — cli.js does not exist. -- [ ] **Step 3: Implement cli.js full surface** +- [ ] **Step 7: Implement `cli.js`** `plugins/adhd/lib/pull-component/cli.js`: @@ -1646,11 +350,7 @@ Expected: 4 new subcommand tests FAIL; original 3 still pass. 'use strict'; const fs = require('node:fs'); -const path = require('node:path'); -const { parseReactComponent } = require('./parse-react'); -const { diffLocalVsFigma } = require('./differ'); -const { applyResolutions } = require('./apply'); -const { addComponentMapping } = require('./config-writer'); +const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('./config-writer'); function parseArgs(argv) { const args = { _: [] }; @@ -1665,11 +365,9 @@ function parseArgs(argv) { function printUsage() { console.log(`Usage: - cli.js parse --output - cli.js extract --output - cli.js diff --local --figma --output - cli.js apply --source --resolutions --output - cli.js config-write --config --path --figma-url `); + cli.js config-write --config --path --figma-url + cli.js config-read --config --path + cli.js config-reverse --config --figma-url `); } function main() { @@ -1678,49 +376,38 @@ function main() { if (args._.length === 0) { printUsage(); process.exit(2); } const cmd = args._[0]; - if (cmd === 'parse') { - const componentPath = args._[1]; - if (!componentPath || !args.output) { console.error('Usage: parse --output '); process.exit(2); } - const source = fs.readFileSync(componentPath, 'utf8'); - const result = parseReactComponent(source); - fs.writeFileSync(args.output, JSON.stringify(result, null, 2)); - process.exit(0); - } - - if (cmd === 'extract') { - // Passthrough: SKILL builds the figma extract via use_figma and writes it to the path. - // This subcommand is a no-op (placeholder for symmetry); validates the file is JSON. - const figmaState = args._[1]; - if (!figmaState || !args.output) { console.error('Usage: extract --output '); process.exit(2); } - const raw = fs.readFileSync(figmaState, 'utf8'); - JSON.parse(raw); // validation - fs.writeFileSync(args.output, raw); - process.exit(0); - } - - if (cmd === 'diff') { - if (!args.local || !args.figma || !args.output) { console.error('Usage: diff --local --figma --output '); process.exit(2); } - const local = JSON.parse(fs.readFileSync(args.local, 'utf8')); - const figma = JSON.parse(fs.readFileSync(args.figma, 'utf8')); - const diff = diffLocalVsFigma(local, figma); - fs.writeFileSync(args.output, JSON.stringify(diff, null, 2)); + if (cmd === 'config-write') { + if (!args.config || !args.path || !args['figma-url']) { + console.error('Usage: config-write --config --path --figma-url '); + process.exit(2); + } + const source = fs.readFileSync(args.config, 'utf8'); + const out = addComponentMapping(source, args.path, args['figma-url']); + fs.writeFileSync(args.config, out); process.exit(0); } - if (cmd === 'apply') { - if (!args.source || !args.resolutions || !args.output) { console.error('Usage: apply --source --resolutions --output '); process.exit(2); } - const source = fs.readFileSync(args.source, 'utf8'); - const resolutions = JSON.parse(fs.readFileSync(args.resolutions, 'utf8')); - const out = applyResolutions(source, resolutions); - fs.writeFileSync(args.output, out); + if (cmd === 'config-read') { + if (!args.config || !args.path) { + console.error('Usage: config-read --config --path '); + process.exit(2); + } + const source = fs.readFileSync(args.config, 'utf8'); + const r = readComponentMapping(source, args.path); + if (!r) { process.exit(1); } + process.stdout.write(r.figma.url); process.exit(0); } - if (cmd === 'config-write') { - if (!args.config || !args.path || !args['figma-url']) { console.error('Usage: config-write --config --path --figma-url '); process.exit(2); } + if (cmd === 'config-reverse') { + if (!args.config || !args['figma-url']) { + console.error('Usage: config-reverse --config --figma-url '); + process.exit(2); + } const source = fs.readFileSync(args.config, 'utf8'); - const out = addComponentMapping(source, args.path, args['figma-url']); - fs.writeFileSync(args.config, out); + const r = reverseLookupPath(source, args['figma-url']); + if (!r) { process.exit(1); } + process.stdout.write(r); process.exit(0); } @@ -1731,32 +418,76 @@ function main() { main(); ``` -- [ ] **Step 4: Verify tests pass** +- [ ] **Step 8: Verify cli tests pass** Run: `node --test plugins/adhd/lib/pull-component/__tests__/cli.test.js` -Expected: All 7 tests PASS. +Expected: 8 tests PASS. + +- [ ] **Step 9: Add README** + +`plugins/adhd/lib/pull-component/README.md`: + +```markdown +# lib/pull-component + +Deterministic config-writer for `/adhd:pull-component`. The skill itself +(at `plugins/adhd/skills/pull-component/SKILL.md`) is the orchestrator +and handles all the LLM-driven work — reading the React source, +extracting the Figma Component Set, computing the diff, prompting the +user, applying Edit-tool changes. + +This library is intentionally tiny: it only contains the schema-level +mutation of `adhd.config.ts` (adding/reading component mappings under +`components..figma.url`). Anything more intelligent lives in +the SKILL prompt where the LLM can reason about it. + +See `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` for the +authoritative spec. +``` + +- [ ] **Step 10: Add CI step** + +Edit `.github/workflows/ci.yml`. In the `lib-tests` job, after the existing `push-component` test step: + +```yaml + - name: Run pull-component tests + run: node --test plugins/adhd/lib/pull-component/__tests__/ +``` + +- [ ] **Step 11: Run all lib tests, verify green** + +Run: `node --test plugins/adhd/lib/lint-engine/__tests__/ plugins/adhd/lib/design-system/__tests__/ plugins/adhd/lib/push-component/__tests__/ plugins/adhd/lib/pull-component/__tests__/` +Expected: all PASS, at least 17 new tests added. -- [ ] **Step 5: Commit** +- [ ] **Step 12: Commit** ```bash -git add plugins/adhd/lib/pull-component/cli.js plugins/adhd/lib/pull-component/__tests__/cli.test.js -git commit -m "cli: wire parse/extract/diff/apply/config-write subcommands" +git add plugins/adhd/lib/pull-component .github/workflows/ci.yml +git commit -m "Add lib/pull-component config-writer + CLI + +Deterministic surface only: read/write components..figma.url +in adhd.config.ts. Everything intelligent (parsing the React source, +diffing against Figma, applying edits) lives in the SKILL prompt +where the LLM handles it. Brittle AST/regex approaches don't apply +when Claude Code is already in the orchestration loop." ``` --- -## Task 8: SKILL.md — orchestrate the 11-phase flow +## Task 2: SKILL.md — the LLM-driven orchestrator **Files:** - Create: `plugins/adhd/skills/pull-component/SKILL.md` -- [ ] **Step 1: Write the SKILL.md** +This is the brain. The prompt must be detailed enough that any Claude Code agent executes it the same way. Every invariant explicit. + +- [ ] **Step 1: Write SKILL.md** `plugins/adhd/skills/pull-component/SKILL.md`: -```markdown +````markdown --- -description: "Pull a Figma Component Set into a React component source file. Inverse of /adhd:push-component. Updates only design-token lookup tables and union types — function body, JSX, hooks, handlers, and imports are never modified. Reads adhd.config.ts and uses the mapping at components..figma.url. Pre-flight validates the Figma source using the same lint engine /adhd:lint uses; structural violations abort the pull." +description: "Pull a Figma Component Set into a React component source file. Inverse of /adhd:push-component. Updates only design-token lookup tables and union type members — function body, JSX, hooks, handlers, and imports are never modified. Reads adhd.config.ts and uses the mapping at components..figma.url. Pre-flight validates the Figma source using the same lint engine /adhd:lint uses; structural violations abort the pull." disable-model-invocation: true argument-hint: " [--allow-unbound]" allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma @@ -1764,160 +495,360 @@ allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use # ADHD Pull Component -Reconciles a Figma Component Set back into a React source file. Symmetric with /adhd:push-component: the same lint engine, the same Tailwind-to-design-token resolver. Updates are scoped to lookup tables (Record) and union type aliases — never the function body. +Reconciles a Figma Component Set back into a React source file. The model (you) is the diff/apply engine: read both sides, compute the diff in working memory, prompt the user, apply edits via the Edit tool. Lookup tables and union types only — the function body is invariant. **Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` +--- + +## Invariants (apply throughout) + +1. **Function body untouched.** You may modify exported type aliases, the props interface, and top-level `Record` (or 2-axis) lookup table object literals. You must NOT modify the exported function declaration, its body, its JSX return, hooks, event handlers, or imports. +2. **Edit tool, not Write.** For updates, use `Edit` calls with `old_string` / `new_string`. Edit preserves whitespace, comments, and surrounding code by construction. Only use `Write` in scaffold mode (creating a new file). +3. **One Component Set per invocation.** If `node-id` resolves to anything else, abort. +4. **Read the spec when in doubt.** The spec at `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` is the contract. + +--- + ## Phase 1: Validate config -Read `adhd.config.ts`. Require `figma.url`. If missing: abort with "Run /adhd:config first to set up ADHD." +Use `Read` on `adhd.config.ts` (in the current working directory). Confirm `figma.url` is set. If the file is missing or `figma.url` is absent, abort: + +> "Run /adhd:config first to set up ADHD." + +Save the resolved file-level Figma URL and file key for later validation. ## Phase 2: Resolve target -Parse `$ARGUMENTS`. First positional is either a path (existing file) or a Figma URL (starts with `https://`). +Parse `$ARGUMENTS`. First positional is either a path (existing file, relative or absolute) or a Figma URL (starts with `https://`). -Use `Bash` to invoke a helper: +Detect `--allow-unbound` flag if present. + +Use `Bash` to invoke the config-writer CLI for path/URL resolution: ```bash -node -e " -const fs = require('fs'); -const { readComponentMapping, reverseLookupPath } = require('./plugins/adhd/lib/pull-component/config-writer'); -const src = fs.readFileSync('adhd.config.ts', 'utf8'); -const arg = process.argv[1]; -if (arg.startsWith('https://')) { - const path = reverseLookupPath(src, arg); - console.log(JSON.stringify({ mode: path ? 'update' : 'scaffold', path, figmaUrl: arg })); -} else { - const entry = readComponentMapping(src, arg); - if (!entry) { console.error('No mapping for ' + arg); process.exit(2); } - console.log(JSON.stringify({ mode: 'update', path: arg, figmaUrl: entry.figma.url })); -} -" "$ARG" +# Path form: +node plugins/adhd/lib/pull-component/cli.js config-read \ + --config adhd.config.ts \ + --path "" +# Exit 0 with URL on stdout = update mode. Exit 1 = no mapping. + +# URL form: +node plugins/adhd/lib/pull-component/cli.js config-reverse \ + --config adhd.config.ts \ + --figma-url "" +# Exit 0 with path on stdout = update mode. Exit 1 = scaffold mode. ``` -Validate the file key in the resolved URL matches `config.figma.url`'s file key. On mismatch abort: "URL points at file , but adhd.config.ts is configured for file ." +Branch: +- **Path form, mapping found:** `update` mode. Use the returned URL. +- **Path form, no mapping (exit 1):** abort with "No Figma mapping for ``. Push it first with /adhd:push-component, or pass a Figma URL to scaffold." +- **URL form, mapping found:** `update` mode. Use the returned path. +- **URL form, no mapping:** `scaffold` mode. Use `AskUserQuestion` to ask: "Where should this component be created? (relative path from adhd.config.ts directory)". Validate via `Bash` that the path doesn't exist (`test ! -e `); if it exists, abort. -If scaffold mode (URL form, no mapping): use `AskUserQuestion` to ask: "Where should this component be created? (relative path from adhd.config.ts directory)". Validate the path doesn't already exist. +Validate that the resolved Figma URL's file key matches `config.figma.url`'s file key (the segment between `/design/` and the next `/`). If different, abort with: "URL points at file ``, but adhd.config.ts is configured for file ``." -Save the resolved `{ mode, path, figmaUrl }` to `/tmp/adhd-pull-component/target.json`. +Save resolved `{ mode, path, figmaUrl }` to working memory. ## Phase 2.5: Pre-flight lint -Extract the Component Set's structural data via `mcp__plugin_figma_figma__use_figma`, scoped to the resolved node-id. Save to `/tmp/adhd-pull-component/ctx.json` and `/tmp/adhd-pull-component/vars.json`. +Extract the Figma node-id from the URL (`?node-id=A-B` → `A:B`). Use `mcp__plugin_figma_figma__use_figma` to: +1. Resolve the node by id; if not a `COMPONENT_SET` or top-level `COMPONENT`, abort: "Target node `` is a ``. Pull requires a Component Set." +2. Serialize the node's structural data (the same way /adhd:lint does for scoped mode — fields: `id, name, type, layoutMode, padding*, itemSpacing, cornerRadius, *Radius, fills, strokes, effects, boundVariables, componentPropertyDefinitions, variantProperties, textStyleId, effectStyleId, characters, fontSize, fontName`, recursing into children). +3. Collect the variable defs (walk boundVariables, look each up via `figma.variables.getVariableByIdAsync`, emit a `{ vars: { 'collection/name': value } }` map). + +Save both via `Bash` heredoc to: +- `/tmp/adhd-pull-component/ctx.json` +- `/tmp/adhd-pull-component/vars.json` -Run the same lint engine /adhd:lint uses: +Run the lint engine: ```bash +mkdir -p /tmp/adhd-pull-component node plugins/adhd/lib/lint-engine/cli.js \ --variable-defs /tmp/adhd-pull-component/vars.json \ --design-context /tmp/adhd-pull-component/ctx.json \ - --globals-css \ + --globals-css example/app/globals.css \ --config adhd.config.ts \ --target "PullComponent Preflight" \ - --target-url "$FIGMA_URL" \ + --target-url "" \ --output /tmp/adhd-pull-component/preflight.md ``` -Parse the report for STRUCT003/004/005 errors specifically (variable-binding violations). Other errors are reported in the final report but do not block. +Use the globals.css path from `config.cssEntry` if set, otherwise auto-detect: `example/app/globals.css` → `app/globals.css` → `src/app/globals.css`. -If variable-binding errors exist AND neither `--allow-unbound` (CLI) nor `components..allowUnboundFigma === true` (config): abort with the helpful error listing each offending layer with its variant path and property (see spec section "Pre-flight lint of the Figma Component Set"). +Use `Read` on `/tmp/adhd-pull-component/preflight.md`. Scan for STRUCT003/004/005 (variable-binding errors). Other rules' violations are noted for the final report but don't block. -If variable-binding errors exist AND the escape is active: render the confirm-prompt via `AskUserQuestion` ("Continue with arbitrary classes? (y/N)"). On `n` or no answer, abort. On `y`, mark offending entries for off-system handling in Phase 7. +**If variable-binding errors exist:** -## Phase 3: Read both sides +Check whether the escape is active: +- `--allow-unbound` CLI flag, OR +- `components..allowUnboundFigma === true` in config (use `Bash` + a small `node -e` to inspect) + +**Without escape:** abort with the helpful error, listing each offending layer: -In scaffold mode, there is no local file to parse; create an empty `local.json` (no unions, no tables) and skip ahead — Phase 7 will materialize a fresh file using all of Figma's values. +``` +✗ Cannot pull — the Figma Component Set has unbound values: -In update mode: + • > — raw (not a variable) + ... + +These need to be bound to design-system variables before we can pull. The designer can: + 1. Bind them in Figma (right-click the layer → "Apply variable") + 2. Or create new variables if these are new design tokens, then run + /adhd:pull-design-system first, then re-run /adhd:pull-component + +We don't generate arbitrary Tailwind classes like text-[20px] or h-[80px] in your +code — those would leak the design system the moment they shipped. +``` + +**With escape:** show the same list, then use `AskUserQuestion`: -```bash -node plugins/adhd/lib/pull-component/cli.js parse --output /tmp/adhd-pull-component/local.json ``` +⚠ The Figma Component Set has unbound values: + ... -For Figma: use `mcp__plugin_figma_figma__use_figma` to walk the Component Set and serialize per-variant per-table tokens. The Figma extract script must produce the shape used in __fixtures__/badge-figma-clean.json — variants with `props` and `tokens` keys. Save to `/tmp/adhd-pull-component/figma.json`. +If you continue, these will land in your code as ARBITRARY Tailwind classes (text-[10px], h-[80px]). +They will be marked with // adhd:off-system comments so they're greppable. +They WILL drift over time and break /adhd:push-component on the round-trip. -## Phase 4: Build the diff +The right fix is to bind these in Figma. This escape is a pragmatic short-term path. -```bash -node plugins/adhd/lib/pull-component/cli.js diff \ - --local /tmp/adhd-pull-component/local.json \ - --figma /tmp/adhd-pull-component/figma.json \ - --output /tmp/adhd-pull-component/diff.json +Continue? [Y] yes / [N] no (abort) +``` + +On `no` or no answer, abort. On `yes`, note which entries will be off-system; you'll prefix their applied values with the `// adhd:off-system — ` comment in Phase 7. + +## Phase 3: Read both sides + +**React side (update mode only):** use `Read` on `` (from Phase 2). Identify: +- The exported function component name (look for `export function (`). +- Exported `type X = "a" | "b" | ...` string-literal unions. +- The component's props interface (`Props`) — note which prop name maps to which union (e.g. `size?: AvatarSize` → axis `size` corresponds to union `AvatarSize`). +- Top-level `export const TABLE: Record = { ... }` and `Record>` lookup tables. + +If the file lacks ALL of (exported function + props interface + at least one Record table), abort: "`` doesn't follow the lookup-table convention. v1 requires it." + +Write a brief structured summary of what you found to `/tmp/adhd-pull-component/local-summary.md` (for forensics and so the final report can reference it). + +**Figma side:** use another `use_figma` call (separate from Phase 2.5's structural extract) that, for every variant in the Component Set, captures the resolved Tailwind class string for each design-token-bearing property on each named layer. + +For each `boundVariables.fills[].id`, you have the variable's `name` (from Phase 2.5's `vars.json`). The mapping from variable name → Tailwind class is direct: +- `color/zinc/800` → `bg-zinc-800` (for a fill) or `text-zinc-800` (for a text color) — disambiguate by the layer/property context. +- `typography/text/xs` → `text-xs`. +- `radius/lg` → `rounded-lg`. +- `spacing/2` → `p-2` / `px-2` / etc. — context-dependent. + +For unbound (raw) values, write the Tailwind arbitrary form: `bg-[#abcdef]`, `text-[10px]`, `rounded-[32px]`. These only appear if Phase 2.5's escape was used. + +Save the result to `/tmp/adhd-pull-component/figma.json` with this shape (write it via `Bash` heredoc with the JSON you compose): + +```json +{ + "componentSetId": "", + "componentName": "", + "variantAxes": { "size": ["xs","sm","md","lg","xl"], ... }, + "variants": [ + { + "props": { "size": "lg", "shape": "circle", "status": "away" }, + "tokens": { + "avatar-body.fill": "bg-zinc-800", + "avatar-body.cornerRadius": "rounded-full", + "initials.fontSize": "text-base", + "initials.fill": "bg-zinc-100", + "status-dot.fill": "bg-amber-500" + } + } + ] +} ``` -Read `diff.json`. If all three buckets are empty AND mode is update: print "No changes" and exit 0. +The `tokens` key is `.`. Layer names come from Figma; properties are one of `fill`, `stroke`, `fontSize`, `cornerRadius`, `padding{Top,Right,Bottom,Left}`, `itemSpacing`, `effectStyle`. + +Hash the JSON (for the Phase 6 drift check) and store the hash in working memory. + +## Phase 4: Diff + +In working memory, walk both sides and produce three buckets: + +1. **`unionDiff`** — for each Figma `variantAxes` entry, compare its values to the corresponding local union. Record adds (Figma has, local doesn't) and removes (local has, Figma doesn't). Skip if no matching local union (becomes `unmapped`). + +2. **`tableDiff`** — for each local lookup table: + - Determine its axis (the union the Record is keyed by, mapped to a prop name via the props interface). + - For each entry in the local table, find Figma variant(s) whose `props[axis]` matches the key. + - The relevant Figma token is the one whose layer/property maps to this table's "thing." This requires knowing what the table affects — use the convention: `SIZE_BOX` and similar h-/w- tables describe the root element; `SIZE_TEXT` describes text size; `STATUS_COLOR` describes a status indicator's fill. + - If the local class string differs from the Figma class string for the matched variant, record a cell diff entry. + +3. **`unmapped`** — Figma axes with no matching local prop/union; local tables whose axis isn't in Figma. + +Write a human-readable summary to `/tmp/adhd-pull-component/diff.md` so the final report can reference it. Keep the structured form in working memory for Phase 5/7. + +If all three buckets are empty AND mode is `update`: print "No changes — Avatar is in sync with Figma." Skip to Phase 11 cleanup. Exit 0. ## Phase 5: Resolve divergences -Top-of-loop short-circuit via `AskUserQuestion`: -- "Apply ALL Figma values" -- "Keep ALL local values (no-op — exits here)" -- "Review each" +Top-of-loop short-circuit via `AskUserQuestion` with these options: + +``` +Pull plan: + • union change(s) + • table(s) with cell changes + • unmapped Figma properties + +How to proceed? + [1] Apply ALL Figma values + [2] Keep ALL local values (no-op — exits) + [3] Review each +``` + +If `Apply ALL`: short-circuit — every unionDiff add accepted, every cell diff accepted (Figma wins). Skip the per-axis/per-table prompts and proceed to Phase 6. + +If `Keep ALL`: skip to Phase 10 final report (nothing applied). + +If `Review each`: + +### 5a — Union changes (asked first) + +For each `unionDiff` entry, ask via `AskUserQuestion`: + +``` +Variant axis `` differs: + Local (): + Figma: + + [1] Add to + cascade entries to all Record<, ...> tables + [2] Skip — leave union as-is (table cells for this axis also skipped) +``` + +For removals: + +``` +Variant axis `` is missing values in Figma: + Local: ... | + Figma: ... + + [1] Remove `` from + all Record<, ...> entries + [2] Skip — keep `` (you may have logic that uses it) +``` + +If the user skips an axis, mark it so Phase 5b's prompts for that axis are also skipped. -If "Apply ALL", short-circuit by writing a resolutions.json that accepts everything Figma proposes (every unionDiff.add, every cell). Skip 5a and 5b. +### 5b — Table cells -If "Review each": +For each table in `tableDiff` (whose axis is NOT skipped from 5a), show: + +``` + (Record<, string>): + + local figma + ───────────────────────────────── + ... + ⚠ changes. + + [1] Apply Figma's values to all cells + [2] Review each one + [3] Keep all local values (skip this table) +``` + +`Review each one` → per-cell: + +``` +. + Local: + Figma: -**5a — Union changes.** For each entry in `diff.unionDiff`, prompt: -- "Add `` to + cascade to all Record<, ...> tables" -- "Skip — leave union as-is (table cells for this axis also skipped)" + [1] Use Figma () + [2] Keep local () +``` -If the user skips an axis, mark it skipped — Phase 5b's per-axis prompts for that axis are NOT shown. +### 5c — Unmapped (informational) -**5b — Table cells.** For each `tableDiff` entry, show the table + cells, prompt: -- "Apply Figma's values to all N cells" -- "Review each one" -- "Keep all local values (skip this table)" +Print, no prompts: -`Review each one` → per-cell binary choice. +``` +ℹ Figma has variant axis/axes with no matching Record<...> table in your code: -**5c — Unmapped.** Print informational notice for each `unmapped` entry (no prompts). + • (Figma values: ...) — add `export type ...` and a Record table, then re-run. +``` -Accumulate into `/tmp/adhd-pull-component/resolutions.json`. For off-system entries from Phase 2.5, prefix each table value with the `// adhd:off-system` comment (literal newline included), so apply.js emits the comment above the property. +Accumulate resolutions in working memory: which union members to add/remove, which cells to apply, which to keep. ## Phase 6: Drift check -Re-fetch the Figma CS, hash the variant tree, compare to the hash from Phase 3 (saved in `/tmp/adhd-pull-component/figma.hash`). On mismatch abort: "Figma changed during pull. Re-run /adhd:pull-component." +Re-fetch the Figma CS via `use_figma`, re-serialize the variants+tokens shape (same script as Phase 3). Hash the JSON, compare to the Phase 3 hash. If different, abort: + +> "Figma changed during pull. Re-run /adhd:pull-component." ## Phase 7: Apply -In scaffold mode: generate the source file from the diff (treat all Figma values as additions). Use a small template — types from `figma.variantAxes`, tables from variants. Write to the target path. The function body is a minimal stub: +**Update mode:** for each resolution, use `Edit` on ``: + +- **Cell update** (`tableDiff` cell accepted): identify the property line in the relevant `Record<...>` table. `Edit` with `old_string` matching the line (including enough context to be unique — usually the key name itself suffices, but include the indent/value if needed), `new_string` with the new value. Preserve trailing comma. + + ``` + Edit: + old_string: " md: \"text-sm\"," + new_string: " md: \"text-base\"," + ``` + +- **Union add** (`unionDiff` add accepted): two-step: + 1. `Edit` the type alias to include the new member. Example: + ``` + old_string: "export type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";" + new_string: "export type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxl\";" + ``` + 2. For each `Record<, ...>` table in the file, `Edit` to insert a new entry. Find the closing brace and insert the new entry just before it, matching the existing indentation. For 2-axis tables, insert into each outer entry's inner Record. + + If a new value is off-system (Phase 2.5 escape was active for this property), prepend a `// adhd:off-system — ` comment on its own line above the new entry. + +- **Union remove** (`unionDiff` remove accepted): + 1. `Edit` the type alias to drop the member. + 2. For each `Record<, ...>` table, `Edit` to remove the corresponding entry (the property line including its trailing newline). + +**Scaffold mode:** compose the new file with `Write`. Template: ```tsx -export function (/* props */) { - return ; // adhd: scaffold stub — replace with your implementation +export type Size = "" | "" | ...; +// ...other axes + +export interface Props { + // axes from Figma variantAxes, optional } -``` -In update mode: +export const _: Record<Size, string> = { + // entries from Figma tokens, one per variant value +}; +// ...other tables -```bash -node plugins/adhd/lib/pull-component/cli.js apply \ - --source \ - --resolutions /tmp/adhd-pull-component/resolutions.json \ - --output /tmp/adhd-pull-component/newsource.tsx +export function (/* props */) { + return ; // adhd: scaffold stub — replace with your implementation +} ``` -Then `Write` `/tmp/adhd-pull-component/newsource.tsx` content back to `` (single Write call — atomic per file). +The function body is intentionally minimal. The user fills it in. ## Phase 8: Write mapping if scaffold mode +Only in `scaffold` mode: + ```bash node plugins/adhd/lib/pull-component/cli.js config-write \ --config adhd.config.ts \ - --path \ - --figma-url + --path "" \ + --figma-url "" ``` ## Phase 9: Per-axis commit -Group applied resolutions by axis (from `diff.json`). For each axis with applied changes: +Group applied resolutions by axis. For each axis with applied changes: ```bash git add [adhd.config.ts] git commit -m "ADHD pull: . ( changes)" ``` +Zero applied changes → no commit. Multiple axes → multiple commits. + ## Phase 10: Final report ``` @@ -1926,6 +857,7 @@ git commit -m "ADHD pull: . ( changes)" - table cells updated - cells kept local - unmapped Figma properties + - off-system entries (use `git grep "adhd:off-system"` to find them) Component file: Figma URL: @@ -1933,7 +865,13 @@ Figma URL: ## Phase 11: Cleanup -Always runs. `rm -rf /tmp/adhd-pull-component`. +Always runs (even on abort): + +```bash +rm -rf /tmp/adhd-pull-component +``` + +--- ## Common errors @@ -1942,89 +880,119 @@ Always runs. `rm -rf /tmp/adhd-pull-component`. | `adhd.config.ts not found` | Run `/adhd:config`. | | `No mapping for ` | Push it first: `/adhd:push-component `. | | `URL points at wrong file` | Open the configured file and copy a node URL from there. | -| `Pre-flight: unbound values` | See the error message — bind values in Figma, or pass `--allow-unbound`. | -| ` has no Record tables` | This component doesn't follow the lookup-table convention. v1 requires it. | +| `Pre-flight: unbound values` | Bind values in Figma, or pass `--allow-unbound`. | +| ` doesn't follow the lookup-table convention` | This component uses inline classes or a non-Record pattern. v1 requires `Record` tables. | | `Figma changed during pull` | Re-run `/adhd:pull-component`. | -``` +| `Edit failed: text not found` | The expected text in the source didn't match. Re-read the file and adjust. | +```` - [ ] **Step 2: Validate SKILL frontmatter** Run: `node scripts/validate-skill-frontmatter.js` -Expected: PASS (the validator checks the frontmatter shape; this SKILL has the same surface as push-component). +Expected: PASS — the validator checks the YAML shape; all required fields present. - [ ] **Step 3: Commit** ```bash git add plugins/adhd/skills/pull-component/ -git commit -m "Add /adhd:pull-component skill orchestrating 11-phase pull flow" +git commit -m "Add /adhd:pull-component skill (LLM-driven orchestrator) + +The skill is the brain: reads the React source, extracts the Figma +Component Set via use_figma, computes the diff in working memory, +prompts via AskUserQuestion, applies edits via the Edit tool. Every +invariant (function body untouched, off-system comment format, +abort conditions) is stated explicitly in the prompt. + +Pre-flight reuses lint-engine via subprocess for STRUCT003/004/005 +enforcement. Config mapping read/written via config-writer CLI." ``` --- -## Task 9: push-component additive — write mapping on first push +## Task 3: push-component additive — write mapping on first push **Files:** - Modify: `plugins/adhd/skills/push-component/SKILL.md` -- [ ] **Step 1: Locate the insertion point in push-component SKILL.md** +The push-component SKILL has phases 1-13. The mapping write goes between Phase 11 (Decide and finalize OR roll back) and Phase 12 (Final report). Only fires on the finalize path. + +- [ ] **Step 1: Read push-component SKILL to confirm phase numbers** -The mapping write should appear between Phase 11 (finalize) and Phase 12 (final report). Only run on the finalize path (when preflight passes or user chose "keep"). +Use `Read` on `plugins/adhd/skills/push-component/SKILL.md` to locate Phase 11 and Phase 12 headings. -- [ ] **Step 2: Insert the new step** +- [ ] **Step 2: Insert new phase between 11 and 12** -Add to `plugins/adhd/skills/push-component/SKILL.md` between Phase 11 and Phase 12: +Use `Edit` to add a new section just before the `## Phase 12: Final report` heading: ```markdown ## Phase 11.5: Write component mapping to adhd.config.ts -Only runs on the finalize path (skip on rollback). +Only runs on the finalize path (skip on rollback — if the user chose roll back in Phase 11, the captured page is gone and there's no mapping to write). + +Determine the relative path of the component file from the directory containing `adhd.config.ts`: + +```bash +RELATIVE_PATH=$(node -e " +const path = require('path'); +const cfgDir = path.dirname(path.resolve('adhd.config.ts')); +const comp = path.resolve(''); +process.stdout.write(path.relative(cfgDir, comp)); +") +``` + +Build the Figma URL with the new page's node-id: + +```bash +FIGMA_URL_BASE=$(node -e " +const { default: cfg } = require(require('path').resolve('adhd.config.ts')); +process.stdout.write(cfg.figma.url.replace(/\/?$/, '/')); +") +NODE_ID_ENCODED=$(echo "$PAGE_ID" | tr ':' '-') +FIGMA_URL="${FIGMA_URL_BASE}?node-id=${NODE_ID_ENCODED}" +``` + +Write the mapping (idempotent — re-pushing the same component does not duplicate the entry): ```bash -RELATIVE_PATH=$(realpath --relative-to=$(dirname adhd.config.ts) ) -FIGMA_URL="?node-id=$(echo $PAGE_ID | tr ':' '-')" node plugins/adhd/lib/pull-component/cli.js config-write \ --config adhd.config.ts \ --path "$RELATIVE_PATH" \ --figma-url "$FIGMA_URL" ``` -This records the mapping so subsequent `/adhd:pull-component ` and `/adhd:pull-component ` invocations can find each other. Idempotent — re-pushing the same component does not duplicate the entry. +This records the mapping so subsequent `/adhd:pull-component ` or `/adhd:pull-component ` invocations can find each other. In v2, push will use this mapping to update the same Component Set instead of creating a new page each time. ``` - [ ] **Step 3: Commit** ```bash git add plugins/adhd/skills/push-component/SKILL.md -git commit -m "push-component: write mapping to adhd.config.ts on finalize" +git commit -m "push-component: write components mapping to adhd.config.ts on finalize" ``` --- -## Task 10: README and marketplace updates +## Task 4: README + marketplace updates **Files:** - Modify: `README.md` - Modify: `.claude-plugin/marketplace.json` -- [ ] **Step 1: Read current README command table** - -Identify lines 19-28 (the command table). The fifth command `/adhd:push-component` is the last row. +- [ ] **Step 1: Update README command table** -- [ ] **Step 2: Add pull-component row to the command table** +Use `Read` on `README.md`. Locate the command table (around lines 19-28) and the "five slash commands" phrase. -Edit `README.md`: +Use `Edit` to change `After install, five slash commands are available:` → `After install, six slash commands are available:`. -Replace `After install, five slash commands are available:` with `After install, six slash commands are available:`. - -Add a row to the command table after `/adhd:push-component`: +Use `Edit` to add a row to the command table after the `/adhd:push-component` row (use enough context to make the Edit unique — match on a few lines around the insertion point): ``` | `/adhd:pull-component` | ` [--allow-unbound]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched) | ``` -- [ ] **Step 3: Add a "Pull a component" subsection** +- [ ] **Step 2: Add "Pull a component" subsection** -After the existing "Push a component" subsection, add: +Use `Edit` to add (after the existing "Push a component" subsection): ```markdown ### Pull a component @@ -2044,11 +1012,11 @@ After the existing "Push a component" subsection, add: The skill reads the Figma Component Set, diffs it against the React file's `Record` lookup tables, prompts on each divergence, and rewrites only those tables (plus union type members). Function body, JSX, hooks, handlers, and imports are never modified. ``` -- [ ] **Step 4: Update marketplace.json description** +- [ ] **Step 3: Update marketplace.json** -`.claude-plugin/marketplace.json` — update the `description` field of the `adhd` plugin to reflect 6 commands. Use the `Read` tool first to see the current value, then `Edit` to update. +Use `Read` on `.claude-plugin/marketplace.json` to see current description. Use `Edit` to update the `adhd` plugin's description string to reflect 6 commands (preserve the existing phrasing style). -- [ ] **Step 5: Commit** +- [ ] **Step 4: Commit** ```bash git add README.md .claude-plugin/marketplace.json @@ -2057,15 +1025,18 @@ git commit -m "README + marketplace: document /adhd:pull-component" --- -## Task 11: Final smoke + PR prep +## Task 5: Final verification + PR - [ ] **Step 1: Run all lib tests** ```bash -node --test plugins/adhd/lib/lint-engine/__tests__/ plugins/adhd/lib/design-system/__tests__/ plugins/adhd/lib/push-component/__tests__/ plugins/adhd/lib/pull-component/__tests__/ +node --test plugins/adhd/lib/lint-engine/__tests__/ \ + plugins/adhd/lib/design-system/__tests__/ \ + plugins/adhd/lib/push-component/__tests__/ \ + plugins/adhd/lib/pull-component/__tests__/ ``` -Expected: all tests PASS. Confirm count is at least 280 (current 251 + ~30 new). +Expected: all PASS. Confirm count ≥ 268 (251 baseline + ~17 new from config-writer + cli tests). - [ ] **Step 2: Run the SKILL frontmatter validator** @@ -2073,9 +1044,9 @@ Expected: all tests PASS. Confirm count is at least 280 (current 251 + ~30 new). node scripts/validate-skill-frontmatter.js ``` -Expected: PASS — all six SKILL.md files have valid frontmatter. +Expected: PASS — all six SKILL.md files validated. -- [ ] **Step 3: Build the example app to sanity-check no regressions** +- [ ] **Step 3: Build the example app to sanity-check** ```bash cd example && npm run build && cd .. @@ -2097,29 +1068,39 @@ gh pr create --title "Add /adhd:pull-component skill" --body "$(cat <<'EOF' Adds `/adhd:pull-component ` — pulls a Figma Component Set back into a React source file. Inverse direction of `/adhd:push-component`. Updates only design-token lookup tables (`Record`) and union type aliases — function body, JSX, hooks, handlers, and imports are never touched. -### Pipeline +## Architecture: LLM as the diff/apply engine + +This skill runs inside Claude Code, so the LLM is already in the orchestration loop. It reads the React source, extracts the Figma Component Set via `use_figma`, computes the diff in working memory, prompts the user via `AskUserQuestion`, and applies changes via `Edit` tool calls. Traditional code is reserved for the deterministic, testable parts: + +- **`lib/pull-component/config-writer.js`** — reads & idempotently writes `components..figma.url` in `adhd.config.ts`. ~150 lines + 9 unit tests. +- **`lint-engine`** (existing, reused) — pre-flight runs the same `checkStructure` that `/adhd:lint` uses. +- **SKILL.md** — the 11-phase orchestrator that handles all the intelligent work via Read/use_figma/AskUserQuestion/Edit. + +The first draft of this plan had `parse-react.js` / `differ.js` / `apply.js` modules. User gut-checked: "Claude Code is the reason we're doing this code gen. I want the intelligence of Claude Code to know how to diff this stuff. I don't want to use rigid, brittle code to do it when we have a full beautiful LLM to do it." Brittle AST manipulation was the wrong abstraction. The revised design pushes intelligence into the SKILL prompt where it belongs. + +## Pipeline 1. Validate config 2. Resolve target (path / URL / scaffold mode) 3. Pre-flight lint of the Figma Component Set (same lint-engine as /adhd:lint) -4. Parse React file (TS compiler API) + extract Figma variants -5. Build the diff (union changes / table cells / unmapped axes) +4. Read React source + extract Figma variants +5. Diff (in working memory) 6. Prompt per-divergence 7. Drift check -8. Apply via AST surgery scoped to unions + tables +8. Apply via Edit tool calls 9. Write component mapping if scaffold mode 10. Per-axis commit 11. Cleanup -### Key design +## Key design - **The React file IS the snapshot** — no parallel state stored in the repo. Lookup tables already encode every design-token value Figma cares about. -- **Bidirectional mapping** in `adhd.config.ts` under `components..figma.url`. Written by push on first push (this PR adds Phase 11.5 to push-component), by pull on first scaffold. +- **Bidirectional mapping** in `adhd.config.ts` under `components..figma.url`. Written by push on first push (Phase 11.5 added to push-component), by pull on first scaffold. - **Symmetric pre-flight**: STRUCT003/004/005 violations on the Figma side block the pull. Designer-side variable discipline enforced in both directions. - **Escape hatch**: `--allow-unbound` (or `allowUnboundFigma: true` in config) converts the abort to a confirm-prompt. Off-system entries land in code with `// adhd:off-system` comments — greppable, self-healing on future pulls. -- **Function body invariant**: AST walker visits only top-level TypeAliasDeclarations and VariableStatements with Record annotations. Function bodies are out-of-bounds. +- **Function body invariant**: the SKILL prompt explicitly tells Claude not to touch function declarations, function bodies, JSX, hooks, handlers, or imports. -### Out of scope (v1) +## Out of scope (v1) - JSX / function body changes — manual only - Multi-component pulls in one command @@ -2127,8 +1108,8 @@ Adds `/adhd:pull-component ` — pulls a Figma Component ## Test plan -- [x] All lib unit tests passing (parse-react, class-resolver, differ, apply, config-writer, cli) -- [x] Integration tests against synthetic Badge fixture with 4 Figma scenarios (clean, cell-change, added-variant, removed-variant) +- [x] config-writer unit tests (9): idempotent add, append-to-existing, update-url, reverse lookup, etc. +- [x] cli surface tests (8): all subcommands, error paths - [x] SKILL frontmatter validated - [x] Example app builds clean - [ ] Manual smoke test: pull-component against the merged-main Avatar component → 0 changes (in sync); manual Figma edit → 1-cell diff → applied → committed @@ -2142,7 +1123,10 @@ Expected: PR URL printed. - [ ] **Step 6: Verify CI is green** -Run: `gh pr checks $(gh pr view --json number -q .number)` +```bash +sleep 30 && gh pr checks $(gh pr view --json number -q .number) +``` + Expected: all checks pass. --- @@ -2153,36 +1137,24 @@ Expected: all checks pass. | Spec section | Task | |---|---| -| Final command surface | Task 8 (SKILL.md), Task 10 (README) | -| Pipeline Phase 1 | Task 8 | -| Pipeline Phase 2 | Task 8 (target resolution); Task 6 (config-writer reverseLookupPath, readComponentMapping) | -| Pipeline Phase 2.5 (pre-flight) | Task 8 (SKILL invokes lint-engine subprocess) | -| Pipeline Phase 3 | Task 8 (SKILL); Task 2 (parse-react) | -| Pipeline Phase 4 | Task 4 (differ) | -| Pipeline Phase 5 | Task 8 (prompt UX in SKILL) | -| Pipeline Phase 6 | Task 8 (drift check) | -| Pipeline Phase 7 | Task 5 (apply) | -| Pipeline Phase 8 | Task 6 (config-writer addComponentMapping) | -| Pipeline Phase 9 | Task 8 (commits) | -| Pipeline Phase 10 | Task 8 (report) | -| Pipeline Phase 11 | Task 8 (cleanup) | -| Lookup-table convention | Task 2 (parse-react implements detection) | -| Config schema additions | Task 6 (config-writer); Task 9 (push-component additive); Task 10 (README documents) | -| Module layout | Tasks 1-7 each create one module | -| Edge cases | Task 8 (SKILL "Common errors" table) | -| Pre-flight escape hatch | Task 8 (SKILL Phase 2.5) | -| Symmetric-pipeline assertions | Task 3 (class-resolver imports lint-engine) | -| Testing strategy | Tasks 1, 2, 3, 4, 5, 6 (each module has __tests__) | -| Acceptance criteria 1-18 | Covered across Tasks 2-11 | +| Final command surface | Task 2 (SKILL.md), Task 4 (README) | +| What lives in code vs. SKILL | Task 1 (lib), Task 2 (SKILL) | +| Pipeline Phases 1-11 | Task 2 | +| Pre-flight escape hatch | Task 2 | +| Config schema additions | Task 1 (config-writer), Task 3 (push-component additive), Task 4 (README) | +| Module layout | Task 1 | +| Edge cases | Task 2 (Common errors table) | +| Acceptance criteria 1-18 | Tasks 1-5 across the board | No gaps. -**Type / signature consistency check:** +**Type / signature consistency:** + +- `readComponentMapping(source, relPath)` → `{ figma: { url } } | null` — Tasks 1, 2 +- `reverseLookupPath(source, figmaUrl)` → `relPath | null` — Tasks 1, 2 +- `addComponentMapping(source, relPath, figmaUrl)` → newSource — Tasks 1, 2, 3 +- CLI exits: 0 on success, 1 on "not found" (config-read/reverse), 2 on usage error — consistent + +**Placeholder scan:** -- `parseReactComponent(source)` → `{ componentName, propsInterfaceName, unions, props, tables, functionBody }` — same signature in Tasks 2, 4, 5, 7 -- `diffLocalVsFigma(local, figma)` → `{ unionDiff, tableDiff, unmapped }` — same in Tasks 4, 7 -- `applyResolutions(source, resolutions)` → `newSource` (string) — same in Tasks 5, 7 -- Resolutions shape `{ unions: { : { add, remove } }, tables: { : { : } } }` — same across Tasks 5, 7, 8 -- `addComponentMapping(source, relPath, figmaUrl)` → newSource — same in Tasks 6, 7, 9 -- `readComponentMapping(source, relPath)` → `{ figma: { url } } | null` — same in Tasks 6, 8 -- `reverseLookupPath(source, figmaUrl)` → `relPath | null` — same in Tasks 6, 8 +Searched for TODO/TBD/FIXME — only legitimate hits are inside markdown code blocks showing intentional placeholders for user customization (e.g. `// adhd: scaffold stub — replace with your implementation`). No actual plan placeholders. diff --git a/docs/superpowers/specs/2026-05-10-adhd-pull-component.md b/docs/superpowers/specs/2026-05-10-adhd-pull-component.md index 4d7d89b..6fc8e57 100644 --- a/docs/superpowers/specs/2026-05-10-adhd-pull-component.md +++ b/docs/superpowers/specs/2026-05-10-adhd-pull-component.md @@ -2,7 +2,7 @@ **Goal:** Inverse of `/adhd:push-component`. Given a target — either a path to an existing React component or a Figma URL — read the corresponding Figma Component Set and reconcile its variant properties, lookup-table values, and union members back into the React source file. Update only the design-token surface (lookup tables, union types); never touch the function body, JSX, handlers, or hooks. Symmetric pipeline: pull's pre-flight validates the Figma source using the same lint engine `/adhd:lint` and `/adhd:push-component`'s preflight use, so structural violations on the Figma side block the pull before any code is rewritten. -**Architectural premise:** The React file is its own snapshot. Top-level `export const X: Record = { ... }` lookup tables (the convention established by the Avatar reference component) already encode every design-token value the Figma Component Set cares about. We never store a parallel snapshot in the repo — we parse the React file at pull time and diff it directly against Figma. The mapping between a React file and its Figma Component Set lives in `adhd.config.ts` under `components..figma.url`, populated automatically by `/adhd:push-component` on first push and by `/adhd:pull-component` on first scaffold. +**Architectural premise:** This skill is invoked from inside Claude Code. The model is already present, can read both sides of the diff in working memory, prompt the user via `AskUserQuestion`, and apply edits via `Edit` tool calls. We use the LLM as the diff/apply engine rather than reinventing brittle TS-compiler-API parsing + golden-file-tested AST surgery in deterministic library code. Library code is reserved for the parts that must be deterministic and testable: the lint engine (already exists, reused) and `adhd.config.ts` mutation (`config-writer`). Everything else is SKILL instructions executed by the model. **Precondition:** the design system has been synced to Figma via `/adhd:push-design-system`; all variables the Component Set references exist locally. The target Component Set must pass the lint engine's variable-binding checks (no raw colors, raw fontSize, raw effects on its layers) — designer-side discipline enforced. @@ -23,7 +23,23 @@ - Pulling JSX structure changes. Pull does not regenerate the function body; renames, prop additions, or layout changes in code remain a manual task. - Bulk pulls. v1 is one component per invocation. - Components that don't follow the `Record` lookup-table convention. v1 reports and aborts; the convention is now documented as part of the plugin's expectations. -- Pulling components whose variant axes correspond to props NOT yet declared in the component file. The asymmetric path ("Figma added an axis the developer hasn't reflected in code") is reported, not auto-resolved. + +--- + +## What lives in code vs. what the SKILL does + +| Concern | Where it lives | Why | +|---|---|---| +| Pre-flight lint (variable-binding violations) | `plugins/adhd/lib/lint-engine/` (existing) | Already deterministic and tested. Reused via subprocess call. | +| `adhd.config.ts` mutation (read & add component mappings) | `plugins/adhd/lib/pull-component/config-writer.js` | Schema-level config edits where determinism + idempotency + tests are valuable. Small surface. | +| Parsing the React file's unions/lookup tables | SKILL (Claude reads the source directly) | The LLM handles variation in component shape gracefully. Brittle pattern-matching would over-constrain the convention. | +| Extracting the Figma Component Set's variants + tokens | SKILL via `use_figma` (no lib helper) | One shot of Plugin API code; result lives in `/tmp/`. | +| Computing the diff (which cells differ, which union members added/removed) | SKILL (Claude reads both `/tmp` files and reasons about them) | The diff is the kind of thing the model does intuitively. No brittle comparator needed. | +| Prompting the user per-divergence | SKILL via `AskUserQuestion` | Standard pattern. | +| Applying changes to the React file | SKILL via `Edit` tool calls | Edit preserves whitespace/comments by design. The model knows which lines to change. | +| Writing the component mapping back to `adhd.config.ts` | `lib/pull-component/config-writer.js` via `Bash` from SKILL | Same determinism argument as config reads. | + +The lib code shrinks to one module: `config-writer.js`. Everything else is SKILL instructions. --- @@ -33,12 +49,12 @@ Phase 1 Validate config Phase 2 Resolve target (path | URL | scaffold mode) Phase 2.5 Pre-flight lint of the Figma Component Set -Phase 3 Read both sides (AST parse React file; extract Figma CS) -Phase 4 Build the diff (unions, table cells, unmapped axes) -Phase 5 Resolve divergences (prompts; batch-confirm affordances) +Phase 3 Read both sides (Claude reads React source; use_figma extracts CS) +Phase 4 Diff (Claude computes inline) +Phase 5 Resolve divergences (prompts via AskUserQuestion) Phase 6 Drift check (re-fetch Figma; abort if remote changed) -Phase 7 Apply to the React file (AST surgery, single Write call) -Phase 8 Write mapping if scaffold mode +Phase 7 Apply to the React file (Edit tool calls) +Phase 8 Write mapping if scaffold mode (config-writer) Phase 9 Per-axis commit Phase 10 Final report Phase 11 Cleanup @@ -57,25 +73,34 @@ Branch on `$ARGUMENTS`: | `` matching a `components` entry | **update** | Use the entry's `figma.url` | | `` with no entry | abort | `"No Figma mapping for . Push it first with /adhd:push-component, or pass a Figma URL to scaffold."` | | `` matching `components.*.figma.url` | **update** | Reverse-lookup the path | -| `` with no mapping | **scaffold** | Ask via `AskUserQuestion`: "Where should this component live?" Validate target path doesn't already exist (else abort). | +| `` with no mapping | **scaffold** | Ask via `AskUserQuestion`: "Where should this component live?" Validate target path doesn't already exist. | + +Path lookup and reverse lookup are done by the `config-writer` subcommands (deterministic, testable). -If the URL's file key doesn't match `config.figma.url`, abort: `"URL points at file , but adhd.config.ts is configured for file ."` (mirrors `/adhd:lint`'s scoped-mode check). +If the URL's file key doesn't match `config.figma.url`, abort: `"URL points at file , but adhd.config.ts is configured for file ."` If `node-id` resolves to a node that isn't a `COMPONENT_SET` or top-level `COMPONENT`, abort: `"Target node is a . Pull requires a Component Set."` -### Phase 2.5 — Pre-flight lint of the Figma Component Set +### Phase 2.5 — Pre-flight lint -Run the same `lint-engine` modules `/adhd:push-component`'s preflight uses, scoped to the target Component Set: +Extract the Component Set's structural data via `use_figma`, scoped to the resolved node-id. Save to `/tmp/adhd-pull-component/ctx.json` and `/tmp/adhd-pull-component/vars.json`. -```js -const designContext = await extractStructuralData(componentSetId); -const variableDefs = await extractVariableDefs(componentSetId); -const violations = checkStructure(designContext, { fileKey, namingConvention: config.naming }); +Run the same lint engine `/adhd:lint` uses: + +```bash +node plugins/adhd/lib/lint-engine/cli.js \ + --variable-defs /tmp/adhd-pull-component/vars.json \ + --design-context /tmp/adhd-pull-component/ctx.json \ + --globals-css \ + --config adhd.config.ts \ + --target "PullComponent Preflight" \ + --target-url "$FIGMA_URL" \ + --output /tmp/adhd-pull-component/preflight.md ``` -Filter violations to *variable-binding errors* (STRUCT003, STRUCT004, STRUCT005). Naming and structural-organization warnings (STRUCT008, STRUCT009) appear in the final report but do not block. +Read the report; locate variable-binding errors (STRUCT003/004/005). Naming and structural-organization warnings (STRUCT008, STRUCT009) appear in the final report but do not block. -**Default behavior (strict):** if any variable-binding errors exist, abort with: +**Default behavior (strict):** if any variable-binding errors exist, abort with the helpful error listing each offending layer with its variant path and property: ``` ✗ Cannot pull — the Figma Component Set has N unbound values: @@ -118,52 +143,51 @@ On confirm, off-system entries land in the React file with `// adhd:off-system` ### Phase 3 — Read both sides -**React side:** read the file with `Read`. AST-parse via the TypeScript compiler API (already a transitive dep through Next.js). Extract: - -| AST node | Output | -|---|---| -| `TypeAliasDeclaration` of `UnionTypeNode>` | `{ : [] }` | -| `InterfaceDeclaration` or `TypeAliasDeclaration` named `Props` | Props mapping (prop name → union type referenced) | -| `VariableStatement` with `VariableDeclaration` typed `Record` | `{ : { : , ... }, axis: }` | -| Nested `Record>` | Two-level table with outer/inner axes | -| Exported function declaration | Component name (sniff only; not modified) | - -What we deliberately ignore: tables typed `Record` where T is not `string`; inline object literals without a `Record` type annotation; tables defined inside the function body; non-Record arrays (e.g. `PALETTE`). - -Save normalized representation to `/tmp/adhd-pull-component/local.json`. +**React side:** in update mode, Claude reads the file directly with `Read` and identifies: +- The exported function component name (declared via `export function (...)`). +- Exported `type X = "a" | "b" | ...` string-literal unions. +- The component's props interface (`Props`) — used to map prop names to union references (e.g. `size?: AvatarSize` → axis `size` maps to union `AvatarSize`). +- Top-level `export const TABLE: Record = { ... }` or `Record>` lookup tables. -**Figma side:** `use_figma` scoped to the Component Set, walking each variant and extracting per-layer bound variables. Save to `/tmp/adhd-pull-component/figma.json`. +In scaffold mode, there is no local file. Phase 7 will materialize a fresh file using all of Figma's values plus a stub function body. -### Phase 4 — Build the diff - -Run a comparator producing `/tmp/adhd-pull-component/diff.json` with three buckets: +**Figma side:** the SKILL runs a `use_figma` script that walks the Component Set and, for every variant, captures the resolved Tailwind-equivalent class strings per design-token-bearing property (fill colors, fontSize, padding, radius, etc.). Output shape (saved to `/tmp/adhd-pull-component/figma.json`): ```json { - "unionDiff": [ - { "union": "AvatarSize", "axis": "size", "add": ["xxl"], "remove": [] } - ], - "tableDiff": [ + "componentSetId": "", + "componentName": "Avatar", + "variantAxes": { "size": ["xs","sm","md","lg","xl"], "shape": ["circle","square"], "status": ["online","away","offline"] }, + "variants": [ { - "table": "SIZE_TEXT", - "axis": "size", - "cells": [ - { "key": "md", "local": "text-sm", "figma": "text-base" }, - { "key": "xl", "local": "text-lg", "figma": "text-xl" } - ] + "props": { "size": "lg", "shape": "circle", "status": "away" }, + "tokens": { + "avatar-body.fill": "bg-zinc-800", + "initials.fontSize": "text-base", + "status-dot.fill": "bg-amber-500" + } } - ], - "unmapped": [ - { "figmaAxis": "theme", "values": ["light", "dark"], "reason": "no Record table found in source" } ] } ``` -The Tailwind-class → design-token resolution reuses `plugins/adhd/lib/lint-engine/variable-categorizer.js` + `theme-parser.js`. Layout-only tokens (`flex`, `items-center`) are ignored when resolving. Size, spacing, color, radius, typography tokens map 1:1 to design system variables. +The mapping from Figma layer/property → Tailwind class is done by reversing the design-system push pipeline (variable id → variable name → Tailwind class via theme-parser, which `lint-engine` already provides). + +The SKILL doesn't need a separate library function for "extract from Figma" — it's one `use_figma` block, encoded in Phase 3 of the SKILL. + +### Phase 4 — Diff + +Claude reads both `/tmp/adhd-pull-component/local-context.md` (a brief summary written by Claude after reading the React file in Phase 3) and `/tmp/adhd-pull-component/figma.json`, then computes the diff in three buckets: + +- **`unionDiff`** — for each Figma `variantAxes` entry whose values don't match the corresponding local union members: which to add, which to remove. +- **`tableDiff`** — for each local lookup table, walk Figma variants; for variants whose props match the table's key axis, compare the Figma resolved token against the local table cell. Record divergences. +- **`unmapped`** — Figma variant axes with no matching local prop/union, OR local tables with an axis that doesn't appear in Figma. + +The diff is held in working memory (and optionally written to `/tmp/adhd-pull-component/diff.md` for the user-facing summary in Phase 5). ### Phase 5 — Resolve divergences -Top-of-loop short-circuit: +Top-of-loop short-circuit via `AskUserQuestion`: ``` Pull plan: @@ -178,111 +202,63 @@ Pull plan: If `Review each`: -**5a — Union changes first.** Per axis: +**5a — Union changes first.** Per axis, prompt to add the new value (and cascade entries to all `Record` tables) or skip. If the user skips an axis, all subsequent table-cell prompts for that axis are also skipped. -``` -Variant axis `size` differs: - Local (AvatarSize): xs | sm | md | lg | xl - Figma: xs | sm | md | lg | xl | xxl +**5b — Table cells next.** Per table with changes, show the table + cells with a 3-way prompt (`Apply Figma's values to all N cells` / `Review each one` / `Keep all local values`). - [1] Add `xxl` to AvatarSize + new entries in all Record tables - [2] Skip — leave union as-is (table cells for this axis also skipped) -``` +**5c — Unmapped, informational only.** Print a notice; no prompts. -Removed-from-Figma case is symmetric: +The resolutions live in Claude's working memory; they don't need a `/tmp/resolutions.json` file because the apply step (Phase 7) is also Claude. -``` - Local (AvatarSize): xs | sm | md | lg | xl | xxl - Figma: xs | sm | md | lg | xl +### Phase 6 — Drift check - [1] Remove `xxl` from AvatarSize + all Record entries - [2] Skip — keep `xxl` in code (you may have logic that uses it) -``` +Re-fetch the Figma CS via `use_figma`, hash the variant subtree, compare to the hash captured at Phase 3. If different, abort: `"Figma changed during pull. Re-run /adhd:pull-component."` -If the user skips an axis, all subsequent table-cell prompts for that axis are skipped automatically. +### Phase 7 — Apply -**5b — Table cells next.** Per table with changes: +**Update mode:** for each resolved change, Claude uses the `Edit` tool to update the React source. Specifically: -``` -SIZE_TEXT (Record): - - size local figma - ────────────────────────────────── - xs text-2xs text-2xs ✓ - sm text-xs text-xs ✓ - md text-sm text-base ⚠ - lg text-base text-base ✓ - xl text-lg text-xl ⚠ - -2 changes. - [1] Apply Figma's values to all 2 cells - [2] Review each one - [3] Keep all local values (skip this table) -``` +- **Cell update:** `Edit` the property value in the relevant `Record<...>` table. The model identifies the exact line and replaces only the value string. +- **Union member add:** `Edit` the `type X = ...` declaration to append the new member, then for each `Record` table in the file, `Edit` to insert the new key with its Figma-resolved value. If the value is off-system (escape hatch active), prepend a `// adhd:off-system — ` comment line above the new entry. +- **Union member remove:** `Edit` the `type X = ...` to remove the member, then `Edit` each `Record` table to drop the corresponding key. -`Review each` prompts per cell with a binary `[1] Use Figma | [2] Keep local`. +The model knows not to touch the function body, JSX, hooks, handlers, or imports — these are explicit invariants in the SKILL prompt. -**5c — Unmapped, informational only:** +**Scaffold mode:** Claude composes a fresh component file matching the lookup-table convention: -``` -ℹ Figma has 1 variant axis with no matching Record<...> table: +```tsx +import React from "react"; - • theme (Figma values: "light" | "dark") +export type Size = "" | "" | ...; +// ...other axes -Pull cannot auto-update unmapped axes. Add `export type AvatarTheme = "light" | "dark"` -and a Record table, then re-run /adhd:pull-component. -``` +export interface Props { + // axes from Figma variantAxes, optional +} -All resolutions accumulate into `/tmp/adhd-pull-component/resolutions.json`: +export const _
: Record<Size, string> = { + // entries from Figma +}; +// ...other tables -```json -{ - "unions": { "AvatarSize": { "add": ["xxl"], "remove": [] } }, - "tables": { - "SIZE_TEXT": { "md": "text-base", "xl": "text-xl" }, - "STATUS_COLOR": { "away": "bg-amber-600" } - } +export function (/* props */) { + return ; // adhd: scaffold stub — replace with your implementation } ``` -### Phase 6 — Drift check - -Re-fetch the Figma CS, hash the relevant subtree, compare to the hash captured in Phase 3. If different, abort: `"Figma changed during pull. Re-run /adhd:pull-component."` - -### Phase 7 — Apply to the React file - -AST-surgery using the TypeScript compiler API's text-replacement APIs. Single `Write` tool call writes the fully transformed source — pull is atomic per file. - -**Touched:** -- Property values in `Record` table literals — replaced in place, preserving surrounding whitespace, indentation, and comments. -- Union member lists on `TypeAliasDeclaration` of `UnionTypeNode` — appended or removed. -- When a union member is added, every `Record` table in the file receives a new key: - - If Figma's bound class resolves cleanly: `xxl: "h-20 w-20"` - - In `--allow-unbound` mode for unbindable values: `// adhd:off-system — figma has no radius variable for 32px` followed by `xxl: "h-[80px] w-[80px] rounded-[32px]"` -- When a union member is removed, the corresponding key is removed from every table. - -**Never touched:** -- Function declarations, function bodies, JSX, hook calls, event handlers, imports (other than no imports are added or removed). -- Lookup tables typed with non-`string` value types. -- Tables defined inside function bodies. - -**Formatting preservation:** -- Detect indentation from the first indented line of the file (2-space / 4-space / tab); mirror it for any inserted lines. -- Preserve CRLF / LF line endings. -- Preserve existing comment positions; new `// adhd:off-system` comments are inserted above their associated table entry. +Written via `Write` to the user-provided target path. ### Phase 8 — Write mapping if scaffold mode -Only runs in scaffold mode. Add `components..figma.url` to `adhd.config.ts` using the same `Edit` tool flow `/adhd:push-component` uses on first push. The added entry matches the parent schema: - -```ts -"app/components/avatar/index.tsx": { - figma: { - url: "https://www.figma.com/design//?node-id=91-18", - }, -} +```bash +node plugins/adhd/lib/pull-component/cli.js config-write \ + --config adhd.config.ts \ + --path \ + --figma-url ``` +In update mode the mapping was used as input but doesn't change, so this step is a no-op. + ### Phase 9 — Per-axis commit Group applied resolutions by variant axis. For each axis touched, one commit: @@ -291,7 +267,7 @@ Group applied resolutions by variant axis. For each axis touched, one commit: git commit -m "ADHD pull: . ( changes)" ``` -Multiple axes → multiple commits. Zero applied changes (user picked "Keep all local") → no commit. +Multiple axes → multiple commits. Zero applied changes → no commit. ### Phase 10 — Final report @@ -314,7 +290,7 @@ Always runs (even on abort). `rm -rf /tmp/adhd-pull-component`. ## The lookup-table convention -The convention is now part of the plugin's documented expectations. Components designed to work with ADHD's push/pull cycle structure their design tokens as: +Components designed to work with ADHD's push/pull cycle structure their design tokens as: ```tsx export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; @@ -347,12 +323,12 @@ export function Avatar({ name, size = "md", shape = "circle" }: AvatarProps) { } ``` -Pull recognizes: -- `AvatarSize`, `AvatarShape` as variant-axis unions. The mapping to Figma's `size`, `shape` variant properties is via the props interface — pull walks `AvatarProps`, finds `size: AvatarSize` and `shape: AvatarShape`, and links each prop name to its union. +Pull recognizes (via Claude reading the source): +- `AvatarSize`, `AvatarShape` as variant-axis unions. The mapping to Figma's `size`, `shape` variant properties is via the props interface — `size: AvatarSize` and `shape: AvatarShape`. - `SIZE_BOX`, `SHAPE` as lookup tables keyed by those unions. Tables get linked to a Figma axis through their key type — `Record` maps to the `size` axis because `AvatarSize` is referenced from the `size` prop. - The component function as a sniff-only target — its existence confirms this is a component file, but its body is invariant. -Tables that don't fit the pattern are reported and ignored. The plugin does not attempt to infer design tokens from arbitrary code shapes — that's a recipe for false positives and silent rewrites. +The convention is documented in the README and in the SKILL prompt. Files that don't follow it are reported and the pull is aborted — the SKILL prompt tells Claude exactly what to look for so that "doesn't follow the convention" is a clear, reproducible determination. --- @@ -390,7 +366,7 @@ Schema rules: - Per-component non-Figma settings live at the same level as `figma` (not inside it). **Writers of `components.*`:** -- `/adhd:push-component`: writes on first successful push (NEW additive Phase 12.5 added to push-component as part of this PR). +- `/adhd:push-component`: writes on first successful push (NEW additive step inserted into push-component's SKILL.md between Phase 11 "Decide and finalize" and Phase 12 "Final report" — only writes on the finalize path, never on rollback). - `/adhd:pull-component`: writes on first successful scaffold-mode pull. **Readers:** @@ -402,18 +378,19 @@ Schema rules: ## Module layout -New library at `plugins/adhd/lib/pull-component/`: +Library at `plugins/adhd/lib/pull-component/`: -| Module | Responsibility | +| File | Responsibility | |---|---| -| `parse-react.js` | TypeScript compiler API walker; extracts unions, props interface, lookup tables, function-body bounds (for invariant assertion). | -| `class-resolver.js` | Re-exports + wraps `lint-engine/variable-categorizer.js` + `theme-parser.js`. Tokenizes multi-class strings, resolves each to `{domain, path, value}` or marks as "layout-only" (ignored). | -| `differ.js` | Pure function: `(localExtract, figmaExtract) → diff.json`. | -| `apply.js` | Pure function: `(sourceText, resolutions) → newSourceText`. Preserves whitespace, comments, line endings. | -| `config-writer.js` | Add/read `components..figma.url` in `adhd.config.ts`. Idempotent. | -| `cli.js` | Subcommand surface: `parse`, `extract`, `diff`, `apply`, `config-write`. Same shape as `push-component/cli.js`. | +| `config-writer.js` | Read & idempotently add `components..figma.url` in `adhd.config.ts`. Also `reverseLookupPath(source, figmaUrl)`. | +| `cli.js` | Single subcommand: `config-write --config --path --figma-url ` plus `config-read --config --path ` and `config-reverse --config --figma-url `. | +| `__tests__/config-writer.test.js` | Unit tests for the three functions (idempotent add, append-to-existing, reverse lookup). | +| `__tests__/cli.test.js` | CLI surface tests. | +| `README.md` | One-paragraph module readme. | -Skill: `plugins/adhd/skills/pull-component/SKILL.md` — orchestrator with `disable-model-invocation: true`, mirroring `push-component`'s phase-by-phase structure. +Skill: `plugins/adhd/skills/pull-component/SKILL.md` — orchestrator with `disable-model-invocation: true`. Contains all the LLM-driven phases (read React, extract Figma, diff, prompt, apply via Edit). The SKILL prompt is detailed enough that Claude can execute it deterministically — every behavior the user can rely on is explicitly described, not left to the model's intuition. + +There is no `parse-react.js`, `differ.js`, or `apply.js`. The model handles those phases. --- @@ -427,19 +404,17 @@ Skill: `plugins/adhd/skills/pull-component/SKILL.md` — orchestrator with `disa | URL points at different file than `config.figma.url` | Abort with file-mismatch error | | `node-id` resolves to non-Component-Set | Abort with type-mismatch error | | Pre-flight finds unbound values, no escape flag | Abort with the "you need variables" error | -| Pre-flight passes, local file has zero recognizable tables | Abort: `" has no Record tables to pull into. v1 requires the lookup-table convention."` | -| Local file references a union we couldn't find in the same file | Warn + skip that axis; report at end as unmapped | -| Local has `Record` but Figma has no matching variant axis | Report as "local-only table"; skip. Common during partial-progress. | -| Multiple tables typed `Record` (legit — SIZE_BOX + SIZE_TEXT + SHAPE) | Prompted independently in Phase 5b | -| Tailwind class the resolver can't parse | Treat as "unknown local value"; show verbatim in diff | +| Pre-flight passes, local file has no recognizable convention (no exported function + props + at least one `Record` table) | Abort: `" doesn't follow the lookup-table convention. v1 requires it."` | +| Local file references a union that's not defined in the same file | Warn + skip that axis; report at end as unmapped | +| Local table indexed by a union not present as a prop type | Report as "local-only table"; skip. Common during partial-progress. | +| Multiple tables typed `Record` (legit — e.g. SIZE_BOX + SIZE_TEXT) | Diff/prompt independently per table | | Figma references a variable that doesn't exist locally | Abort: `"Figma references variables not in your design system. Run /adhd:pull-design-system first."` | | Drift check (Phase 6) detects remote change | Abort: `"Figma changed during pull. Re-run /adhd:pull-component."` | -| AST write fails | Abort with the write error. Atomic per file (no partial state). | +| `Edit` tool call fails (e.g. expected text not found) | Surface the underlying error; the SKILL aborts the pull. No partial state because Edit failures don't write. | | User aborts mid-prompt (Ctrl-C) | Apply nothing; print `"Aborted. No changes."`; cleanup runs | | Scaffold mode: target path already exists | Abort: `" already exists. Pass a different path or delete it first."` | | `--allow-unbound` with clean Figma | Flag has no effect; proceeds normally | | Component name in file ≠ Figma CS name | Warn but proceed | -| Source uses CRLF / tabs / 2-space / 4-space indentation | Detected from existing file; preserved through apply | --- @@ -450,13 +425,13 @@ When `--allow-unbound` (CLI) OR `components..allowUnboundFigma === true` ( 1. Show the unbound-values list with what they'll become in code (e.g. `text-[20px]`). 2. Confirm prompt: continue with arbitrary classes? (default: No). 3. On confirm: - - Apply proceeds, off-system entries get the `// adhd:off-system — ` comment in the file. + - Apply proceeds; off-system entries get the `// adhd:off-system — ` comment in the file (via the same Edit-tool path Claude uses for in-system entries). - Final report includes a line: `⚠ N entries are off-system. Bind in Figma to bring them back in-system.` The `// adhd:off-system` comment is: - **Greppable:** `git grep "adhd:off-system"` lists all drift sources. -- **Self-healing:** when the value is bound in Figma, the next pull replaces the arbitrary class with the proper one AND removes the comment. -- **Future-aware:** v2 can ship an `OFFSYSTEM_USAGE` lint rule that surfaces these in `/adhd:lint` output as drift hotspots. +- **Self-healing:** when the value is bound in Figma, the next pull replaces the arbitrary class with the proper one and removes the comment (the model is explicitly told to do this in the SKILL prompt). +- **Future-aware:** v2 can ship an `OFFSYSTEM_USAGE` lint rule that surfaces these in `/adhd:lint` output. **Round-trip consequence (intentional):** off-system code in React fails `/adhd:push-component`'s preflight on the way back. This forces a discussion: bind it in Figma, or define new variables and `/adhd:pull-design-system` them. The escape hatch is not a permanent crutch. @@ -466,65 +441,58 @@ The `// adhd:off-system` comment is: | Assertion | Mechanism | |---|---| -| `class-resolver.js` imports — never duplicates — `lint-engine` Tailwind-resolution logic | Module re-exports from `lint-engine/variable-categorizer.js` + `lint-engine/theme-parser.js`; tested in `__tests__/class-resolver.test.js` | -| Pre-flight uses the same `checkStructure` that `/adhd:lint` and `/adhd:push-component`'s preflight use | Phase 2.5 invokes `lint-engine`'s structure-checker directly; tested by running a known-violation fixture | -| Round-trip stability: push-then-pull produces a no-op diff | Smoke-test acceptance criterion + integration fixture | +| Pre-flight uses the same `checkStructure` that `/adhd:lint` and `/adhd:push-component` preflight use | Phase 2.5 invokes `lint-engine`'s CLI as a subprocess; same code path | +| `adhd.config.ts` mapping is read & written by both push and pull through the same `config-writer.js` module | Push-component's new mapping-write step invokes the same CLI subcommand pull uses | +| Round-trip stability: push-then-pull produces a no-op diff for clean components | Verified via the manual smoke test acceptance criterion (the model can't be unit-tested for byte-identity, but the round-trip property is testable end-to-end) | --- ## Testing strategy -**Layer 1 — Unit tests on each module.** `plugins/adhd/lib/pull-component/__tests__/`: +The model-driven nature of Phases 3–7 changes the test pyramid. We test the deterministic bits with traditional unit tests; we test the LLM bits via reproducible end-to-end fixtures and manual smoke tests, not golden-byte diffs. + +**Layer 1 — Unit tests on deterministic lib code:** | Module | Coverage | |---|---| -| `parse-react.js` | Extract Avatar's unions, props, 5 lookup tables; verify multi-axis tables; assert function-body bounds recorded and never visited | -| `class-resolver.js` | Multi-token strings split; layout tokens ignored; size/color/radius/typography map cleanly; reuses lint-engine code | -| `differ.js` | Pure function: clean (no diff), single cell, added union, removed union, unmapped Figma axis, unmapped local table | -| `apply.js` | Pure function: cell update preserves comments, union append, union remove cascades, no-op resolutions return byte-identical | -| `config-writer.js` | Idempotent on re-add; preserves key order | -| `cli.js` | Each subcommand surface (same pattern as push-component CLI tests) | +| `config-writer.js` `addComponentMapping` | Adds entry when missing; idempotent on re-add; appends to existing components; updates URL if different | +| `config-writer.js` `readComponentMapping` | Returns `{ figma: { url } }` or `null` | +| `config-writer.js` `reverseLookupPath` | Returns the relative path or `null` | +| `cli.js` config subcommands | Each surface returns exit 0 on success; exit 2 on usage error | -**Layer 2 — Integration with real-figma fixtures.** `plugins/adhd/lib/pull-component/__fixtures__/`: +**Layer 2 — SKILL-driven behavior (verified by reading the SKILL prompt itself):** -| Fixture | Asserts | -|---|---| -| `avatar-clean.json` | Diff is empty; apply is byte-identical no-op | -| `avatar-cell-change.json` | 1 cell diff; apply rewrites just that line | -| `avatar-added-variant.json` | Union member appended; new key cascades to all SIZE_* tables | -| `avatar-removed-variant.json` | Inverse | -| `avatar-unbound-fill.json` | Pre-flight aborts; error lists the layer path | -| `avatar-unbound-with-flag.json` | With `--allow-unbound`: off-system comment lands in output | +The SKILL.md is the contract for what the model does. The plan includes a **SKILL prompt review** task — a fresh subagent reads the spec + the SKILL.md and asks: "Is every phase described concretely enough that any Claude Code agent would execute it the same way? Are the invariants (function body untouched, off-system comment format, abort conditions) stated explicitly?" Findings are addressed before merge. -Golden source-text files (`avatar-base.tsx`, `avatar-after-.tsx`) committed alongside; tests assert byte-for-byte match after apply. +**Layer 3 — End-to-end smoke test (manual):** -**Layer 3 — End-to-end smoke test.** Manual, run against the merged-main Avatar source + Figma CS `91:18` in file `PBCAkpPnvGXWrz6H7qfH3V`: +1. Start from a clean local Avatar source. `/adhd:pull-component app/components/avatar/index.tsx` → "No changes" and exits 0. +2. Make a small Figma edit (rebind one variant's color in the Avatar CS, `91:18`). +3. Re-run pull → 1-cell diff, prompted, applied, committed. Verify the function body is untouched and the change lands in the correct lookup table. +4. Revert the Figma edit, re-run → detects drift the other way, prompts to revert local. +5. Test the escape hatch: deliberately unbind one Figma value, run `/adhd:pull-component --allow-unbound`, verify off-system comment lands and the rest of the file is preserved. -1. Start from merged-main Avatar. -2. `/adhd:pull-component app/components/avatar/index.tsx` → "No changes" (in sync). -3. Make a single Figma edit (rebind one variant's color). -4. Re-run pull → 1-cell diff, prompted, applied, committed. -5. Revert Figma edit, re-run → detects drift in the opposite direction, prompts to revert local. +Documented in the spec as a manual acceptance check, not automated CI. --- ## Acceptance criteria 1. `/adhd:pull-component app/components/avatar/index.tsx` against in-sync Figma produces "No changes" and exits 0. -2. With one cell changed in Figma, pull surfaces the diff, prompts, applies, and commits. +2. With one cell changed in Figma, pull surfaces the diff, prompts, applies (via Edit tool), and commits. 3. With a new variant value in Figma (`size=xxl`), pull prompts to extend the union and cascades the new key through all `Record` tables. 4. With a removed variant value in Figma, pull prompts and removes from the union + all tables. -5. URL form: `/adhd:pull-component ` reverse-resolves to the path from `components.*.figma.url`. -6. URL form with no matching mapping enters scaffold mode, prompts for target path, writes the new file + the mapping. +5. URL form: `/adhd:pull-component ` reverse-resolves to the path from `components.*.figma.url` via `config-writer`. +6. URL form with no matching mapping enters scaffold mode, prompts for target path, writes the new file via `Write`, and adds the mapping via `config-writer`. 7. Pre-flight blocks the pull when Figma has unbound values; the error lists each offending layer with its variant path and property. 8. `--allow-unbound` (or `allowUnboundFigma: true` in config) converts the abort to a confirm-prompt; on confirm, hardcoded arbitrary classes land in the file with `// adhd:off-system` comments. 9. URL points at a different Figma file than `config.figma.url` → abort with the file-mismatch error. -10. Function body, JSX, hooks, handlers, and imports are never modified — verified by golden diff in Layer 2 tests. -11. CRLF line endings, tabs vs spaces, and existing comment positions are preserved through apply. +10. Function body, JSX, hooks, handlers, and imports are never modified — the SKILL prompt explicitly states this invariant and Claude is responsible for honoring it. +11. The SKILL prompt names the lookup-table convention precisely enough that Claude can detect "doesn't follow the convention" reproducibly. 12. Drift check runs between extract and apply; if Figma changed during the user's deliberation, abort with "Re-run pull-component". 13. Per-axis commit: `git commit -m "ADHD pull: avatar.size (3 changes)"` lands per axis touched; multiple axes → multiple commits; zero changes → zero commits. -14. The `class-resolver` module imports from `lint-engine` — no duplicate Tailwind-to-design-token resolution logic. -15. Re-running `/adhd:pull-component` after `/adhd:push-component` on the same component produces a no-op diff (round-trip stability assertion). +14. Pre-flight invokes `lint-engine/cli.js` as a subprocess — no duplicate structural-lint logic. (`class-resolver.js` and similar bridges are NOT introduced; the SKILL handles class-to-token resolution by reading globals.css and resolving as needed.) +15. Re-running `/adhd:pull-component` after `/adhd:push-component` on the same component produces a no-op diff (round-trip stability assertion via manual smoke test). 16. Pull adds `components.` to `adhd.config.ts` automatically in scaffold mode, in the `{ figma: { url } }` shape matching the parent config schema. 17. README's command table includes the `/adhd:pull-component` row (enforced by the AGENTS.md "keep README in sync" convention). -18. `/adhd:push-component` writes the same `components..figma.url` mapping on first push (additive step inserted into push-component's SKILL.md, between Phase 11 "Decide and finalize" and Phase 12 "Final report" — only writes the mapping on the finalize path, never on rollback). +18. `/adhd:push-component` writes the same `components..figma.url` mapping on first push (additive step inserted into push-component's SKILL.md between Phase 11 and Phase 12 — only writes on the finalize path, never on rollback). From a4e223af28acf266b6ee103365ab34bf76217c23 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:29:44 -0400 Subject: [PATCH 4/9] Add lib/pull-component config-writer + CLI Deterministic surface only: read/write components..figma.url in adhd.config.ts. Everything intelligent (parsing the React source, diffing against Figma, applying edits) lives in the SKILL prompt where the LLM handles it. Brittle AST/regex approaches don't apply when Claude Code is already in the orchestration loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 + plugins/adhd/lib/pull-component/README.md | 15 + .../lib/pull-component/__tests__/cli.test.js | 69 ++++ .../__tests__/config-writer.test.js | 71 ++++ plugins/adhd/lib/pull-component/cli.js | 70 ++++ .../adhd/lib/pull-component/config-writer.js | 337 ++++++++++++++++++ 6 files changed, 564 insertions(+) create mode 100644 plugins/adhd/lib/pull-component/README.md create mode 100644 plugins/adhd/lib/pull-component/__tests__/cli.test.js create mode 100644 plugins/adhd/lib/pull-component/__tests__/config-writer.test.js create mode 100644 plugins/adhd/lib/pull-component/cli.js create mode 100644 plugins/adhd/lib/pull-component/config-writer.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6b755..f641179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: run: node --test plugins/adhd/lib/design-system/__tests__/ - name: Run push-component tests run: node --test plugins/adhd/lib/push-component/__tests__/ + - name: Run pull-component tests + run: node --test plugins/adhd/lib/pull-component/__tests__/ hygiene: name: project hygiene diff --git a/plugins/adhd/lib/pull-component/README.md b/plugins/adhd/lib/pull-component/README.md new file mode 100644 index 0000000..423bdee --- /dev/null +++ b/plugins/adhd/lib/pull-component/README.md @@ -0,0 +1,15 @@ +# lib/pull-component + +Deterministic config-writer for `/adhd:pull-component`. The skill itself +(at `plugins/adhd/skills/pull-component/SKILL.md`) is the orchestrator +and handles all the LLM-driven work — reading the React source, +extracting the Figma Component Set, computing the diff, prompting the +user, applying Edit-tool changes. + +This library is intentionally tiny: it only contains the schema-level +mutation of `adhd.config.ts` (adding/reading component mappings under +`components..figma.url`). Anything more intelligent lives in +the SKILL prompt where the LLM can reason about it. + +See `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` for the +authoritative spec. diff --git a/plugins/adhd/lib/pull-component/__tests__/cli.test.js b/plugins/adhd/lib/pull-component/__tests__/cli.test.js new file mode 100644 index 0000000..5df60d3 --- /dev/null +++ b/plugins/adhd/lib/pull-component/__tests__/cli.test.js @@ -0,0 +1,69 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const CLI = path.resolve(__dirname, '..', 'cli.js'); + +function tmp(filename, content) { + const p = path.join(os.tmpdir(), 'adhd-pull-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '-' + filename); + fs.writeFileSync(p, content); + return p; +} + +test('cli with --help prints subcommand usage and exits 0', () => { + const r = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' }); + assert.equal(r.status, 0); + assert.match(r.stdout, /Usage:/); + assert.match(r.stdout, /config-write/); + assert.match(r.stdout, /config-read/); + assert.match(r.stdout, /config-reverse/); +}); + +test('cli with no args exits 2', () => { + assert.equal(spawnSync('node', [CLI], { encoding: 'utf8' }).status, 2); +}); + +test('cli with unknown subcommand exits 2', () => { + assert.equal(spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }).status, 2); +}); + +test('config-write subcommand adds a components entry to the config file', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-write', '--config', cfgPath, '--path', 'app/components/x.tsx', '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const after = fs.readFileSync(cfgPath, 'utf8'); + assert.match(after, /"app\/components\/x\.tsx":/); +}); + +test('config-read subcommand prints the figma url to stdout', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n components: {\n "app/components/x.tsx": { figma: { url: "https://figma.com/design/ABC/?node-id=1-1" } },\n },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-read', '--config', cfgPath, '--path', 'app/components/x.tsx'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /node-id=1-1/); +}); + +test('config-read exits 1 with empty stdout when path is not mapped', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-read', '--config', cfgPath, '--path', 'app/components/missing.tsx'], { encoding: 'utf8' }); + assert.equal(r.status, 1); + assert.equal(r.stdout, ''); +}); + +test('config-reverse subcommand prints the path for a given URL', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n components: {\n "app/components/x.tsx": { figma: { url: "https://figma.com/design/ABC/?node-id=1-1" } },\n },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-reverse', '--config', cfgPath, '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /app\/components\/x\.tsx/); +}); + +test('config-reverse exits 1 with empty stdout when URL has no mapping', () => { + const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`); + const r = spawnSync('node', [CLI, 'config-reverse', '--config', cfgPath, '--figma-url', 'https://figma.com/design/ABC/?node-id=9-9'], { encoding: 'utf8' }); + assert.equal(r.status, 1); + assert.equal(r.stdout, ''); +}); diff --git a/plugins/adhd/lib/pull-component/__tests__/config-writer.test.js b/plugins/adhd/lib/pull-component/__tests__/config-writer.test.js new file mode 100644 index 0000000..041eae6 --- /dev/null +++ b/plugins/adhd/lib/pull-component/__tests__/config-writer.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('../config-writer'); + +const MINIMAL_CONFIG = `const config = { + figma: { url: "https://figma.com/design/ABC/" }, +}; + +export default config; +`; + +const WITH_COMPONENTS = `const config = { + figma: { url: "https://figma.com/design/ABC/" }, + components: { + "app/components/avatar/index.tsx": { + figma: { url: "https://figma.com/design/ABC/?node-id=91-18" }, + }, + }, +}; + +export default config; +`; + +test('readComponentMapping returns null when no components field exists', () => { + assert.equal(readComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx'), null); +}); + +test('readComponentMapping returns entry when path matches', () => { + const r = readComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx'); + assert.equal(r && r.figma.url, 'https://figma.com/design/ABC/?node-id=91-18'); +}); + +test('readComponentMapping returns null for an absent path even if components exists', () => { + assert.equal(readComponentMapping(WITH_COMPONENTS, 'app/components/nope.tsx'), null); +}); + +test('addComponentMapping creates components field if missing', () => { + const out = addComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + assert.match(out, /components:\s*\{/); + assert.match(out, /"app\/components\/badge\.tsx":/); + assert.match(out, /url:\s*"https:\/\/figma\.com\/design\/ABC\/\?node-id=200-1"/); +}); + +test('addComponentMapping is idempotent — re-adding same entry returns identical source', () => { + const out1 = addComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + const out2 = addComponentMapping(out1, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + assert.equal(out2, out1); +}); + +test('addComponentMapping appends to existing components field', () => { + const out = addComponentMapping(WITH_COMPONENTS, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1'); + assert.match(out, /"app\/components\/avatar\/index\.tsx":/); + assert.match(out, /"app\/components\/badge\.tsx":/); +}); + +test('addComponentMapping updates existing entry if URL differs', () => { + const out = addComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx', 'https://figma.com/design/ABC/?node-id=999-1'); + assert.match(out, /node-id=999-1/); + assert.doesNotMatch(out, /node-id=91-18/); +}); + +test('reverseLookupPath finds the path for a given figma URL', () => { + const path = reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=91-18'); + assert.equal(path, 'app/components/avatar/index.tsx'); +}); + +test('reverseLookupPath returns null for unknown URL', () => { + assert.equal(reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=999-1'), null); +}); diff --git a/plugins/adhd/lib/pull-component/cli.js b/plugins/adhd/lib/pull-component/cli.js new file mode 100644 index 0000000..78f18c1 --- /dev/null +++ b/plugins/adhd/lib/pull-component/cli.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('./config-writer'); + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { args.help = true; continue; } + if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; } + else { args._.push(a); } + } + return args; +} + +function printUsage() { + console.log(`Usage: + cli.js config-write --config --path --figma-url + cli.js config-read --config --path + cli.js config-reverse --config --figma-url `); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { printUsage(); process.exit(0); } + if (args._.length === 0) { printUsage(); process.exit(2); } + const cmd = args._[0]; + + if (cmd === 'config-write') { + if (!args.config || !args.path || !args['figma-url']) { + console.error('Usage: config-write --config --path --figma-url '); + process.exit(2); + } + const source = fs.readFileSync(args.config, 'utf8'); + const out = addComponentMapping(source, args.path, args['figma-url']); + fs.writeFileSync(args.config, out); + process.exit(0); + } + + if (cmd === 'config-read') { + if (!args.config || !args.path) { + console.error('Usage: config-read --config --path '); + process.exit(2); + } + const source = fs.readFileSync(args.config, 'utf8'); + const r = readComponentMapping(source, args.path); + if (!r) { process.exit(1); } + process.stdout.write(r.figma.url); + process.exit(0); + } + + if (cmd === 'config-reverse') { + if (!args.config || !args['figma-url']) { + console.error('Usage: config-reverse --config --figma-url '); + process.exit(2); + } + const source = fs.readFileSync(args.config, 'utf8'); + const r = reverseLookupPath(source, args['figma-url']); + if (!r) { process.exit(1); } + process.stdout.write(r); + process.exit(0); + } + + console.error('Unknown subcommand: ' + cmd); + process.exit(2); +} + +main(); diff --git a/plugins/adhd/lib/pull-component/config-writer.js b/plugins/adhd/lib/pull-component/config-writer.js new file mode 100644 index 0000000..161dc27 --- /dev/null +++ b/plugins/adhd/lib/pull-component/config-writer.js @@ -0,0 +1,337 @@ +'use strict'; + +// Regex-based reader/writer for `adhd.config.ts`. The file shape we parse: +// +// const config = { +// figma: { url: "..." }, +// components: { +// "app/components/avatar/index.tsx": { +// figma: { url: "..." }, +// }, +// }, +// }; +// +// export default config; +// +// The codebase is zero-deps and parses TS-flavored sources with regex +// elsewhere (see lib/push-component/parse-component.js). Brace-counting +// is used to find the END of nested blocks, since regex alone can't +// match balanced braces. + +const CONFIG_OPEN_RE = /\bconst\s+config\s*=\s*\{/; +const COMPONENTS_OPEN_RE = /\bcomponents\s*:\s*\{/; + +function escapeForRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Walks `source` from `openBraceIdx` (which MUST point at a `{`), +// returns the index of the matching `}` (inclusive). Throws if +// unmatched. Skips over braces inside double-quoted strings and +// `//` / `/* */` comments to be safe against tokens like `"{"`. +function findMatchingBrace(source, openBraceIdx) { + if (source[openBraceIdx] !== '{') { + throw new Error('findMatchingBrace: position ' + openBraceIdx + ' is not `{`'); + } + let depth = 0; + let i = openBraceIdx; + while (i < source.length) { + const c = source[i]; + // String literal — skip to closing quote, honoring backslash escapes. + if (c === '"' || c === "'" || c === '`') { + const quote = c; + i++; + while (i < source.length && source[i] !== quote) { + if (source[i] === '\\') i += 2; + else i++; + } + i++; + continue; + } + // Line comment + if (c === '/' && source[i + 1] === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + // Block comment + if (c === '/' && source[i + 1] === '*') { + i += 2; + while (i < source.length - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++; + i += 2; + continue; + } + if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) return i; + } + i++; + } + throw new Error('findMatchingBrace: unmatched `{` starting at ' + openBraceIdx); +} + +// Locates the top-level config object's brace range: { start, end } +// where start = index of `{` and end = index of matching `}`. +function findConfigObjectRange(source) { + const m = CONFIG_OPEN_RE.exec(source); + if (!m) return null; + const openIdx = source.indexOf('{', m.index); + if (openIdx === -1) return null; + const closeIdx = findMatchingBrace(source, openIdx); + return { start: openIdx, end: closeIdx }; +} + +// Locates the `components:` field's brace range INSIDE the given config +// range, or null if not present. +function findComponentsRange(source, configRange) { + COMPONENTS_OPEN_RE.lastIndex = 0; + // Scan only the slice inside the config object. + const slice = source.slice(configRange.start + 1, configRange.end); + const m = COMPONENTS_OPEN_RE.exec(slice); + if (!m) return null; + const absoluteMatchIdx = configRange.start + 1 + m.index; + const openIdx = source.indexOf('{', absoluteMatchIdx); + if (openIdx === -1 || openIdx >= configRange.end) return null; + const closeIdx = findMatchingBrace(source, openIdx); + return { start: openIdx, end: closeIdx }; +} + +// Walks the top-level keys inside a `{ ... }` object range. Yields each +// entry's brace-balanced span. Used for iterating `components: { ... }` +// entries one path at a time. +// +// Yields objects of shape: +// { keyStart, keyEnd, key, valueStart, valueEnd } +// where: +// - `key` is the (unquoted) property name +// - keyStart..keyEnd is the range of the key including its quotes (if quoted) +// - valueStart..valueEnd is the range of the value (for an object value, +// these are the `{` and `}` indexes inclusive) +function* iterateObjectEntries(source, objectRange) { + // Skip the opening `{`. + let i = objectRange.start + 1; + const end = objectRange.end; + while (i < end) { + // Skip whitespace, commas, comments. + while (i < end) { + const c = source[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === ',') { + i++; + continue; + } + if (c === '/' && source[i + 1] === '/') { + while (i < end && source[i] !== '\n') i++; + continue; + } + if (c === '/' && source[i + 1] === '*') { + i += 2; + while (i < end - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++; + i += 2; + continue; + } + break; + } + if (i >= end) return; + + // Parse a key. Either "quoted string" or bare identifier. + let keyStart = i; + let keyEnd; + let key; + if (source[i] === '"' || source[i] === "'") { + const quote = source[i]; + i++; + const keyTextStart = i; + while (i < end && source[i] !== quote) { + if (source[i] === '\\') i += 2; + else i++; + } + key = source.slice(keyTextStart, i); + i++; // consume closing quote + keyEnd = i; + } else { + const idMatch = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(source.slice(i, end)); + if (!idMatch) return; // unparseable — bail + key = idMatch[0]; + i += idMatch[0].length; + keyEnd = i; + } + + // Skip whitespace + `:`. + while (i < end && (source[i] === ' ' || source[i] === '\t' || source[i] === '\n' || source[i] === '\r')) i++; + if (source[i] !== ':') return; // malformed + i++; + while (i < end && (source[i] === ' ' || source[i] === '\t' || source[i] === '\n' || source[i] === '\r')) i++; + + // Parse value. For our purposes we only need to handle: + // - object literal `{...}` (the only thing we care about for components mapping) + // - string literal "..." or '...' + // - any other primitive — skip until next `,` or end of object. + let valueStart = i; + let valueEnd; + if (source[i] === '{') { + valueEnd = findMatchingBrace(source, i); + i = valueEnd + 1; + } else if (source[i] === '"' || source[i] === "'") { + const quote = source[i]; + i++; + while (i < end && source[i] !== quote) { + if (source[i] === '\\') i += 2; + else i++; + } + i++; // consume closing quote + valueEnd = i - 1; + } else { + // Skip arbitrary tokens until a comma or closing brace at depth 0. + let depth = 0; + while (i < end) { + const c = source[i]; + if (c === '{' || c === '[' || c === '(') depth++; + else if (c === '}' || c === ']' || c === ')') { + if (depth === 0) break; + depth--; + } else if (c === ',' && depth === 0) { + break; + } else if (c === '"' || c === "'" || c === '`') { + const quote = c; + i++; + while (i < end && source[i] !== quote) { + if (source[i] === '\\') i += 2; + else i++; + } + } + i++; + } + valueEnd = i - 1; + } + + yield { keyStart, keyEnd, key, valueStart, valueEnd }; + } +} + +// Find the `figma: { url: "X" }` inside a given entry value range (which is +// the `{` and `}` indexes of the entry's object literal). Returns the +// span of the quoted url STRING LITERAL (including the surrounding +// quotes) and the url text itself, or null. +function findFigmaUrlInEntry(source, entryValueRange) { + // Walk the entry's top-level entries looking for `figma:`. + for (const child of iterateObjectEntries(source, entryValueRange)) { + if (child.key !== 'figma') continue; + // child.valueStart..valueEnd is `{...}` of figma. Look for url inside. + if (source[child.valueStart] !== '{') return null; + const figmaRange = { start: child.valueStart, end: child.valueEnd }; + for (const sub of iterateObjectEntries(source, figmaRange)) { + if (sub.key !== 'url') continue; + // sub.valueStart..valueEnd is the inner-quote span. We want the + // quote characters themselves. + // valueStart is the position of the opening quote, valueEnd is the + // index of the closing quote. + const openQuote = sub.valueStart; + const closeQuote = sub.valueEnd; + if (source[openQuote] !== '"' && source[openQuote] !== "'") return null; + const urlText = source.slice(openQuote + 1, closeQuote); + return { quoteStart: openQuote, quoteEnd: closeQuote, urlText }; + } + return null; + } + return null; +} + +function readComponentMapping(source, relPath) { + const cfg = findConfigObjectRange(source); + if (!cfg) return null; + const comps = findComponentsRange(source, cfg); + if (!comps) return null; + for (const entry of iterateObjectEntries(source, comps)) { + if (entry.key !== relPath) continue; + if (source[entry.valueStart] !== '{') return null; + const urlInfo = findFigmaUrlInEntry(source, { start: entry.valueStart, end: entry.valueEnd }); + if (!urlInfo) return null; + return { figma: { url: urlInfo.urlText } }; + } + return null; +} + +function reverseLookupPath(source, figmaUrl) { + const cfg = findConfigObjectRange(source); + if (!cfg) return null; + const comps = findComponentsRange(source, cfg); + if (!comps) return null; + for (const entry of iterateObjectEntries(source, comps)) { + if (source[entry.valueStart] !== '{') continue; + const urlInfo = findFigmaUrlInEntry(source, { start: entry.valueStart, end: entry.valueEnd }); + if (!urlInfo) continue; + if (urlInfo.urlText === figmaUrl) return entry.key; + } + return null; +} + +// Find the indent (whitespace prefix) of the line containing `pos`. +function lineIndent(source, pos) { + let lineStart = pos; + while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--; + let i = lineStart; + while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i++; + return source.slice(lineStart, i); +} + +function addComponentMapping(source, relPath, figmaUrl) { + // Idempotency: if existing entry already matches, return source unchanged. + const existing = readComponentMapping(source, relPath); + if (existing && existing.figma.url === figmaUrl) return source; + + const cfg = findConfigObjectRange(source); + if (!cfg) throw new Error('addComponentMapping: could not find `const config = { ... }`'); + + const comps = findComponentsRange(source, cfg); + + // Case 1: existing components. with a different URL → replace url inline. + if (comps) { + for (const entry of iterateObjectEntries(source, comps)) { + if (entry.key !== relPath) continue; + if (source[entry.valueStart] !== '{') break; + const urlInfo = findFigmaUrlInEntry(source, { start: entry.valueStart, end: entry.valueEnd }); + if (!urlInfo) break; + // Replace the contents BETWEEN the quotes (preserving the quote chars). + return ( + source.slice(0, urlInfo.quoteStart + 1) + + figmaUrl + + source.slice(urlInfo.quoteEnd) + ); + } + // Case 2: components exists but not this path → append new entry before + // the closing brace. Use indentation matched from the existing first + // entry; fall back to " " (4-space indent inside `components:`). + const firstEntry = iterateObjectEntries(source, comps).next().value; + let entryIndent = ' '; + let innerIndent = ' '; + if (firstEntry) { + entryIndent = lineIndent(source, firstEntry.keyStart); + innerIndent = entryIndent + ' '; + } + const insert = `${entryIndent}"${relPath}": {\n${innerIndent}figma: { url: "${figmaUrl}" },\n${entryIndent}},\n`; + return source.slice(0, comps.end) + insert + source.slice(comps.end); + } + + // Case 3: no components field → insert one before the closing brace of + // `const config`. Use indent matched from existing top-level config props. + const firstCfgEntry = iterateObjectEntries(source, cfg).next().value; + let baseIndent = ' '; + if (firstCfgEntry) { + baseIndent = lineIndent(source, firstCfgEntry.keyStart); + } + const innerIndent = baseIndent + ' '; + const innerInnerIndent = innerIndent + ' '; + const insert = + `${baseIndent}components: {\n` + + `${innerIndent}"${relPath}": {\n` + + `${innerInnerIndent}figma: { url: "${figmaUrl}" },\n` + + `${innerIndent}},\n` + + `${baseIndent}},\n`; + return source.slice(0, cfg.end) + insert + source.slice(cfg.end); +} + +module.exports = { + readComponentMapping, + reverseLookupPath, + addComponentMapping, +}; From b903919ad8ee10df5509f669ed7d9f6161a5fa86 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:32:37 -0400 Subject: [PATCH 5/9] config-writer: drop dead code and tighten a redundant comment - Remove unused escapeForRegex helper. - Remove COMPONENTS_OPEN_RE.lastIndex reset; the regex has no /g flag so lastIndex is never set by exec(), making the reset meaningless. - Collapse a 4-line comment that just restated variable names into a 2-line note about the iterator's quote-index invariant. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/lib/pull-component/config-writer.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/plugins/adhd/lib/pull-component/config-writer.js b/plugins/adhd/lib/pull-component/config-writer.js index 161dc27..1f4b344 100644 --- a/plugins/adhd/lib/pull-component/config-writer.js +++ b/plugins/adhd/lib/pull-component/config-writer.js @@ -21,10 +21,6 @@ const CONFIG_OPEN_RE = /\bconst\s+config\s*=\s*\{/; const COMPONENTS_OPEN_RE = /\bcomponents\s*:\s*\{/; -function escapeForRegex(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - // Walks `source` from `openBraceIdx` (which MUST point at a `{`), // returns the index of the matching `}` (inclusive). Throws if // unmatched. Skips over braces inside double-quoted strings and @@ -84,7 +80,6 @@ function findConfigObjectRange(source) { // Locates the `components:` field's brace range INSIDE the given config // range, or null if not present. function findComponentsRange(source, configRange) { - COMPONENTS_OPEN_RE.lastIndex = 0; // Scan only the slice inside the config object. const slice = source.slice(configRange.start + 1, configRange.end); const m = COMPONENTS_OPEN_RE.exec(slice); @@ -221,10 +216,8 @@ function findFigmaUrlInEntry(source, entryValueRange) { const figmaRange = { start: child.valueStart, end: child.valueEnd }; for (const sub of iterateObjectEntries(source, figmaRange)) { if (sub.key !== 'url') continue; - // sub.valueStart..valueEnd is the inner-quote span. We want the - // quote characters themselves. - // valueStart is the position of the opening quote, valueEnd is the - // index of the closing quote. + // For a string value, iterateObjectEntries sets valueStart/valueEnd + // to the indexes of the opening and closing quote characters. const openQuote = sub.valueStart; const closeQuote = sub.valueEnd; if (source[openQuote] !== '"' && source[openQuote] !== "'") return null; From 2fc6eeaad3a3b97213cc50beb1d82e4d9dc798c6 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:34:40 -0400 Subject: [PATCH 6/9] Add /adhd:pull-component skill (LLM-driven orchestrator) The skill is the brain: reads the React source, extracts the Figma Component Set via use_figma, computes the diff in working memory, prompts via AskUserQuestion, applies edits via the Edit tool. Every invariant (function body untouched, off-system comment format, abort conditions) is stated explicitly in the prompt. Pre-flight reuses lint-engine via subprocess for STRUCT003/004/005 enforcement. Config mapping read/written via config-writer CLI. --- plugins/adhd/skills/pull-component/SKILL.md | 398 ++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 plugins/adhd/skills/pull-component/SKILL.md diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md new file mode 100644 index 0000000..f9fb08c --- /dev/null +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -0,0 +1,398 @@ +--- +description: "Pull a Figma Component Set into a React component source file. Inverse of /adhd:push-component. Updates only design-token lookup tables and union type members — function body, JSX, hooks, handlers, and imports are never modified. Reads adhd.config.ts and uses the mapping at components..figma.url. Pre-flight validates the Figma source using the same lint engine /adhd:lint uses; structural violations abort the pull." +disable-model-invocation: true +argument-hint: " [--allow-unbound]" +allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma +--- + +# ADHD Pull Component + +Reconciles a Figma Component Set back into a React source file. The model (you) is the diff/apply engine: read both sides, compute the diff in working memory, prompt the user, apply edits via the Edit tool. Lookup tables and union types only — the function body is invariant. + +**Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` + +--- + +## Invariants (apply throughout) + +1. **Function body untouched.** You may modify exported type aliases, the props interface, and top-level `Record` (or 2-axis) lookup table object literals. You must NOT modify the exported function declaration, its body, its JSX return, hooks, event handlers, or imports. +2. **Edit tool, not Write.** For updates, use `Edit` calls with `old_string` / `new_string`. Edit preserves whitespace, comments, and surrounding code by construction. Only use `Write` in scaffold mode (creating a new file). +3. **One Component Set per invocation.** If `node-id` resolves to anything else, abort. +4. **Read the spec when in doubt.** The spec at `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` is the contract. + +--- + +## Phase 1: Validate config + +Use `Read` on `adhd.config.ts` (in the current working directory). Confirm `figma.url` is set. If the file is missing or `figma.url` is absent, abort: + +> "Run /adhd:config first to set up ADHD." + +Save the resolved file-level Figma URL and file key for later validation. + +## Phase 2: Resolve target + +Parse `$ARGUMENTS`. First positional is either a path (existing file, relative or absolute) or a Figma URL (starts with `https://`). + +Detect `--allow-unbound` flag if present. + +Use `Bash` to invoke the config-writer CLI for path/URL resolution: + +```bash +# Path form: +node plugins/adhd/lib/pull-component/cli.js config-read \ + --config adhd.config.ts \ + --path "" +# Exit 0 with URL on stdout = update mode. Exit 1 = no mapping. + +# URL form: +node plugins/adhd/lib/pull-component/cli.js config-reverse \ + --config adhd.config.ts \ + --figma-url "" +# Exit 0 with path on stdout = update mode. Exit 1 = scaffold mode. +``` + +Branch: +- **Path form, mapping found:** `update` mode. Use the returned URL. +- **Path form, no mapping (exit 1):** abort with "No Figma mapping for ``. Push it first with /adhd:push-component, or pass a Figma URL to scaffold." +- **URL form, mapping found:** `update` mode. Use the returned path. +- **URL form, no mapping:** `scaffold` mode. Use `AskUserQuestion` to ask: "Where should this component be created? (relative path from adhd.config.ts directory)". Validate via `Bash` that the path doesn't exist (`test ! -e `); if it exists, abort. + +Validate that the resolved Figma URL's file key matches `config.figma.url`'s file key (the segment between `/design/` and the next `/`). If different, abort with: "URL points at file ``, but adhd.config.ts is configured for file ``." + +Save resolved `{ mode, path, figmaUrl }` to working memory. + +## Phase 2.5: Pre-flight lint + +Extract the Figma node-id from the URL (`?node-id=A-B` → `A:B`). Use `mcp__plugin_figma_figma__use_figma` to: +1. Resolve the node by id; if not a `COMPONENT_SET` or top-level `COMPONENT`, abort: "Target node `` is a ``. Pull requires a Component Set." +2. Serialize the node's structural data (the same way /adhd:lint does for scoped mode — fields: `id, name, type, layoutMode, padding*, itemSpacing, cornerRadius, *Radius, fills, strokes, effects, boundVariables, componentPropertyDefinitions, variantProperties, textStyleId, effectStyleId, characters, fontSize, fontName`, recursing into children). +3. Collect the variable defs (walk boundVariables, look each up via `figma.variables.getVariableByIdAsync`, emit a `{ vars: { 'collection/name': value } }` map). + +Save both via `Bash` heredoc to: +- `/tmp/adhd-pull-component/ctx.json` +- `/tmp/adhd-pull-component/vars.json` + +Run the lint engine: + +```bash +mkdir -p /tmp/adhd-pull-component +node plugins/adhd/lib/lint-engine/cli.js \ + --variable-defs /tmp/adhd-pull-component/vars.json \ + --design-context /tmp/adhd-pull-component/ctx.json \ + --globals-css example/app/globals.css \ + --config adhd.config.ts \ + --target "PullComponent Preflight" \ + --target-url "" \ + --output /tmp/adhd-pull-component/preflight.md +``` + +Use the globals.css path from `config.cssEntry` if set, otherwise auto-detect: `example/app/globals.css` → `app/globals.css` → `src/app/globals.css`. + +Use `Read` on `/tmp/adhd-pull-component/preflight.md`. Scan for STRUCT003/004/005 (variable-binding errors). Other rules' violations are noted for the final report but don't block. + +**If variable-binding errors exist:** + +Check whether the escape is active: +- `--allow-unbound` CLI flag, OR +- `components..allowUnboundFigma === true` in config (use `Bash` + a small `node -e` to inspect) + +**Without escape:** abort with the helpful error, listing each offending layer: + +``` +✗ Cannot pull — the Figma Component Set has unbound values: + + • > — raw (not a variable) + ... + +These need to be bound to design-system variables before we can pull. The designer can: + 1. Bind them in Figma (right-click the layer → "Apply variable") + 2. Or create new variables if these are new design tokens, then run + /adhd:pull-design-system first, then re-run /adhd:pull-component + +We don't generate arbitrary Tailwind classes like text-[20px] or h-[80px] in your +code — those would leak the design system the moment they shipped. +``` + +**With escape:** show the same list, then use `AskUserQuestion`: + +``` +⚠ The Figma Component Set has unbound values: + ... + +If you continue, these will land in your code as ARBITRARY Tailwind classes (text-[10px], h-[80px]). +They will be marked with // adhd:off-system comments so they're greppable. +They WILL drift over time and break /adhd:push-component on the round-trip. + +The right fix is to bind these in Figma. This escape is a pragmatic short-term path. + +Continue? [Y] yes / [N] no (abort) +``` + +On `no` or no answer, abort. On `yes`, note which entries will be off-system; you'll prefix their applied values with the `// adhd:off-system — ` comment in Phase 7. + +## Phase 3: Read both sides + +**React side (update mode only):** use `Read` on `` (from Phase 2). Identify: +- The exported function component name (look for `export function (`). +- Exported `type X = "a" | "b" | ...` string-literal unions. +- The component's props interface (`Props`) — note which prop name maps to which union (e.g. `size?: AvatarSize` → axis `size` corresponds to union `AvatarSize`). +- Top-level `export const TABLE: Record = { ... }` and `Record>` lookup tables. + +If the file lacks ALL of (exported function + props interface + at least one Record table), abort: "`` doesn't follow the lookup-table convention. v1 requires it." + +Write a brief structured summary of what you found to `/tmp/adhd-pull-component/local-summary.md` (for forensics and so the final report can reference it). + +**Figma side:** use another `use_figma` call (separate from Phase 2.5's structural extract) that, for every variant in the Component Set, captures the resolved Tailwind class string for each design-token-bearing property on each named layer. + +For each `boundVariables.fills[].id`, you have the variable's `name` (from Phase 2.5's `vars.json`). The mapping from variable name → Tailwind class is direct: +- `color/zinc/800` → `bg-zinc-800` (for a fill) or `text-zinc-800` (for a text color) — disambiguate by the layer/property context. +- `typography/text/xs` → `text-xs`. +- `radius/lg` → `rounded-lg`. +- `spacing/2` → `p-2` / `px-2` / etc. — context-dependent. + +For unbound (raw) values, write the Tailwind arbitrary form: `bg-[#abcdef]`, `text-[10px]`, `rounded-[32px]`. These only appear if Phase 2.5's escape was used. + +Save the result to `/tmp/adhd-pull-component/figma.json` with this shape (write it via `Bash` heredoc with the JSON you compose): + +```json +{ + "componentSetId": "", + "componentName": "", + "variantAxes": { "size": ["xs","sm","md","lg","xl"], ... }, + "variants": [ + { + "props": { "size": "lg", "shape": "circle", "status": "away" }, + "tokens": { + "avatar-body.fill": "bg-zinc-800", + "avatar-body.cornerRadius": "rounded-full", + "initials.fontSize": "text-base", + "initials.fill": "bg-zinc-100", + "status-dot.fill": "bg-amber-500" + } + } + ] +} +``` + +The `tokens` key is `.`. Layer names come from Figma; properties are one of `fill`, `stroke`, `fontSize`, `cornerRadius`, `padding{Top,Right,Bottom,Left}`, `itemSpacing`, `effectStyle`. + +Hash the JSON (for the Phase 6 drift check) and store the hash in working memory. + +## Phase 4: Diff + +In working memory, walk both sides and produce three buckets: + +1. **`unionDiff`** — for each Figma `variantAxes` entry, compare its values to the corresponding local union. Record adds (Figma has, local doesn't) and removes (local has, Figma doesn't). Skip if no matching local union (becomes `unmapped`). + +2. **`tableDiff`** — for each local lookup table: + - Determine its axis (the union the Record is keyed by, mapped to a prop name via the props interface). + - For each entry in the local table, find Figma variant(s) whose `props[axis]` matches the key. + - The relevant Figma token is the one whose layer/property maps to this table's "thing." This requires knowing what the table affects — use the convention: `SIZE_BOX` and similar h-/w- tables describe the root element; `SIZE_TEXT` describes text size; `STATUS_COLOR` describes a status indicator's fill. + - If the local class string differs from the Figma class string for the matched variant, record a cell diff entry. + +3. **`unmapped`** — Figma axes with no matching local prop/union; local tables whose axis isn't in Figma. + +Write a human-readable summary to `/tmp/adhd-pull-component/diff.md` so the final report can reference it. Keep the structured form in working memory for Phase 5/7. + +If all three buckets are empty AND mode is `update`: print "No changes — Avatar is in sync with Figma." Skip to Phase 11 cleanup. Exit 0. + +## Phase 5: Resolve divergences + +Top-of-loop short-circuit via `AskUserQuestion` with these options: + +``` +Pull plan: + • union change(s) + • table(s) with cell changes + • unmapped Figma properties + +How to proceed? + [1] Apply ALL Figma values + [2] Keep ALL local values (no-op — exits) + [3] Review each +``` + +If `Apply ALL`: short-circuit — every unionDiff add accepted, every cell diff accepted (Figma wins). Skip the per-axis/per-table prompts and proceed to Phase 6. + +If `Keep ALL`: skip to Phase 10 final report (nothing applied). + +If `Review each`: + +### 5a — Union changes (asked first) + +For each `unionDiff` entry, ask via `AskUserQuestion`: + +``` +Variant axis `` differs: + Local (): + Figma: + + [1] Add to + cascade entries to all Record<, ...> tables + [2] Skip — leave union as-is (table cells for this axis also skipped) +``` + +For removals: + +``` +Variant axis `` is missing values in Figma: + Local: ... | + Figma: ... + + [1] Remove `` from + all Record<, ...> entries + [2] Skip — keep `` (you may have logic that uses it) +``` + +If the user skips an axis, mark it so Phase 5b's prompts for that axis are also skipped. + +### 5b — Table cells + +For each table in `tableDiff` (whose axis is NOT skipped from 5a), show: + +``` + (Record<, string>): + + local figma + ───────────────────────────────── + ... + ⚠ changes. + + [1] Apply Figma's values to all cells + [2] Review each one + [3] Keep all local values (skip this table) +``` + +`Review each one` → per-cell: + +``` +. + Local: + Figma: + + [1] Use Figma () + [2] Keep local () +``` + +### 5c — Unmapped (informational) + +Print, no prompts: + +``` +ℹ Figma has variant axis/axes with no matching Record<...> table in your code: + + • (Figma values: ...) — add `export type ...` and a Record table, then re-run. +``` + +Accumulate resolutions in working memory: which union members to add/remove, which cells to apply, which to keep. + +## Phase 6: Drift check + +Re-fetch the Figma CS via `use_figma`, re-serialize the variants+tokens shape (same script as Phase 3). Hash the JSON, compare to the Phase 3 hash. If different, abort: + +> "Figma changed during pull. Re-run /adhd:pull-component." + +## Phase 7: Apply + +**Update mode:** for each resolution, use `Edit` on ``: + +- **Cell update** (`tableDiff` cell accepted): identify the property line in the relevant `Record<...>` table. `Edit` with `old_string` matching the line (including enough context to be unique — usually the key name itself suffices, but include the indent/value if needed), `new_string` with the new value. Preserve trailing comma. + + ``` + Edit: + old_string: " md: \"text-sm\"," + new_string: " md: \"text-base\"," + ``` + +- **Union add** (`unionDiff` add accepted): two-step: + 1. `Edit` the type alias to include the new member. Example: + ``` + old_string: "export type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";" + new_string: "export type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxl\";" + ``` + 2. For each `Record<, ...>` table in the file, `Edit` to insert a new entry. Find the closing brace and insert the new entry just before it, matching the existing indentation. For 2-axis tables, insert into each outer entry's inner Record. + + If a new value is off-system (Phase 2.5 escape was active for this property), prepend a `// adhd:off-system — ` comment on its own line above the new entry. + +- **Union remove** (`unionDiff` remove accepted): + 1. `Edit` the type alias to drop the member. + 2. For each `Record<, ...>` table, `Edit` to remove the corresponding entry (the property line including its trailing newline). + +**Scaffold mode:** compose the new file with `Write`. Template: + +```tsx +export type Size = "" | "" | ...; +// ...other axes + +export interface Props { + // axes from Figma variantAxes, optional +} + +export const _
: Record<Size, string> = { + // entries from Figma tokens, one per variant value +}; +// ...other tables + +export function (/* props */) { + return ; // adhd: scaffold stub — replace with your implementation +} +``` + +The function body is intentionally minimal. The user fills it in. + +## Phase 8: Write mapping if scaffold mode + +Only in `scaffold` mode: + +```bash +node plugins/adhd/lib/pull-component/cli.js config-write \ + --config adhd.config.ts \ + --path "" \ + --figma-url "" +``` + +## Phase 9: Per-axis commit + +Group applied resolutions by axis. For each axis with applied changes: + +```bash +git add [adhd.config.ts] +git commit -m "ADHD pull: . ( changes)" +``` + +Zero applied changes → no commit. Multiple axes → multiple commits. + +## Phase 10: Final report + +``` +✓ Pulled from Figma: + - variant(s) added + - table cells updated + - cells kept local + - unmapped Figma properties + - off-system entries (use `git grep "adhd:off-system"` to find them) + +Component file: +Figma URL: +``` + +## Phase 11: Cleanup + +Always runs (even on abort): + +```bash +rm -rf /tmp/adhd-pull-component +``` + +--- + +## Common errors + +| Error | Fix-up guidance | +|---|---| +| `adhd.config.ts not found` | Run `/adhd:config`. | +| `No mapping for ` | Push it first: `/adhd:push-component `. | +| `URL points at wrong file` | Open the configured file and copy a node URL from there. | +| `Pre-flight: unbound values` | Bind values in Figma, or pass `--allow-unbound`. | +| ` doesn't follow the lookup-table convention` | This component uses inline classes or a non-Record pattern. v1 requires `Record` tables. | +| `Figma changed during pull` | Re-run `/adhd:pull-component`. | +| `Edit failed: text not found` | The expected text in the source didn't match. Re-read the file and adjust. | From a26831e6db1db598e027e54c311a28f9955e3e8c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:37:15 -0400 Subject: [PATCH 7/9] pull-component SKILL: clarify ambiguous prompt directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 3: spell out the variable-id → name resolution path. The prior text suggested reading the variable name from Phase 2.5's vars.json, but that map is keyed by name (not id), so the lookup was unreachable. Now the SKILL tells Claude to call getVariableByIdAsync directly and describes the intermediate shape to return from use_figma. - Phase 4: replace the hardcoded "Avatar" placeholder in the in-sync message with . - Phase 5: align the "Keep ALL" option label with the resolution — it proceeds to the final report rather than exiting silently. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/pull-component/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index f9fb08c..11076dd 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -145,7 +145,9 @@ Write a brief structured summary of what you found to `/tmp/adhd-pull-component/ **Figma side:** use another `use_figma` call (separate from Phase 2.5's structural extract) that, for every variant in the Component Set, captures the resolved Tailwind class string for each design-token-bearing property on each named layer. -For each `boundVariables.fills[].id`, you have the variable's `name` (from Phase 2.5's `vars.json`). The mapping from variable name → Tailwind class is direct: +The JS you pass to `use_figma` must, for each variant COMPONENT in the set: walk its named children; for each child read its `variantProperties` (on the parent COMPONENT) and `boundVariables`; for every bound variable id, call `await figma.variables.getVariableByIdAsync(id)` and read `.name` (and its collection's name via `getVariableCollectionByIdAsync(variable.variableCollectionId).name`) to form a `'/'` key. Return a structure that lets you build the `figma.json` shape below — e.g. `[{ props, layers: [{ name, fills, strokes, fontSize, cornerRadius, padding*, itemSpacing, effectStyleId, boundVarNames: { fill: 'color/zinc/800', ... } }] }]`. Do NOT rely on Phase 2.5's `vars.json` for the id→name lookup — that map is keyed by name, not id. + +The mapping from variable name → Tailwind class is direct: - `color/zinc/800` → `bg-zinc-800` (for a fill) or `text-zinc-800` (for a text color) — disambiguate by the layer/property context. - `typography/text/xs` → `text-xs`. - `radius/lg` → `rounded-lg`. @@ -195,7 +197,7 @@ In working memory, walk both sides and produce three buckets: Write a human-readable summary to `/tmp/adhd-pull-component/diff.md` so the final report can reference it. Keep the structured form in working memory for Phase 5/7. -If all three buckets are empty AND mode is `update`: print "No changes — Avatar is in sync with Figma." Skip to Phase 11 cleanup. Exit 0. +If all three buckets are empty AND mode is `update`: print "No changes — is in sync with Figma." Skip to Phase 11 cleanup. Exit 0. ## Phase 5: Resolve divergences @@ -209,7 +211,7 @@ Pull plan: How to proceed? [1] Apply ALL Figma values - [2] Keep ALL local values (no-op — exits) + [2] Keep ALL local values (no edits — proceeds to final report) [3] Review each ``` From ad21d95c6fa0cfb89f814e2370533ca32e4b2495 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:38:15 -0400 Subject: [PATCH 8/9] push-component: write components mapping to adhd.config.ts on finalize --- plugins/adhd/skills/push-component/SKILL.md | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/plugins/adhd/skills/push-component/SKILL.md b/plugins/adhd/skills/push-component/SKILL.md index 9266860..aaf850c 100644 --- a/plugins/adhd/skills/push-component/SKILL.md +++ b/plugins/adhd/skills/push-component/SKILL.md @@ -198,6 +198,43 @@ Then print "Rolled back. No changes to Figma. Fix the issues in your component a If user picks keep, proceed to Phase 12. +## Phase 11.5: Write component mapping to adhd.config.ts + +Only runs on the finalize path (skip on rollback — if the user chose roll back in Phase 11, the captured page is gone and there's no mapping to write). + +Determine the relative path of the component file from the directory containing `adhd.config.ts`: + +```bash +RELATIVE_PATH=$(node -e " +const path = require('path'); +const cfgDir = path.dirname(path.resolve('adhd.config.ts')); +const comp = path.resolve(''); +process.stdout.write(path.relative(cfgDir, comp)); +") +``` + +Build the Figma URL with the new page's node-id: + +```bash +FIGMA_URL_BASE=$(node -e " +const { default: cfg } = require(require('path').resolve('adhd.config.ts')); +process.stdout.write(cfg.figma.url.replace(/\/?$/, '/')); +") +NODE_ID_ENCODED=$(echo "$PAGE_ID" | tr ':' '-') +FIGMA_URL="${FIGMA_URL_BASE}?node-id=${NODE_ID_ENCODED}" +``` + +Write the mapping (idempotent — re-pushing the same component does not duplicate the entry): + +```bash +node plugins/adhd/lib/pull-component/cli.js config-write \ + --config adhd.config.ts \ + --path "$RELATIVE_PATH" \ + --figma-url "$FIGMA_URL" +``` + +This records the mapping so subsequent `/adhd:pull-component ` or `/adhd:pull-component ` invocations can find each other. In v2, push will use this mapping to update the same Component Set instead of creating a new page each time. + ## Phase 12: Final report Print: From aaaf86d960c1cc49a8d50c6b2ed82cfaf1db9db6 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 10 May 2026 20:39:58 -0400 Subject: [PATCH 9/9] README + marketplace: document /adhd:pull-component --- .claude-plugin/marketplace.json | 2 +- README.md | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fa4ecb1..123891b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ { "name": "adhd", "source": "./plugins/adhd", - "description": "Push, pull, and lint design tokens between Tailwind v4 and Figma; push React components with preflight validation." + "description": "Push, pull, and lint design tokens between Tailwind v4 and Figma; push and pull React components with preflight validation." } ] } diff --git a/README.md b/README.md index 2c113ae..077a03e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Plugin source lives at the repo root (`plugins/`, `docs/`, `scripts/`, etc.). Th Both commands are persistent — Claude Code remembers the marketplace and the enabled plugin across sessions. Run them once per machine. -After install, five slash commands are available: +After install, six slash commands are available: | Command | Args | Direction | What it does | |---|---|---|---| @@ -25,6 +25,7 @@ After install, five slash commands are available: | `/adhd:push-design-system` | — | code → Figma | Pushes globals.css variables + named styles into Figma directly via the remote MCP | | `/adhd:pull-design-system` | — | Figma → code | Pulls Figma variables + named styles into globals.css | | `/adhd:push-component` | ` [--max-variants ]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check | +| `/adhd:pull-component` | ` [--allow-unbound]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched) | `/adhd:push-design-system`, `/adhd:pull-design-system`, `/adhd:lint`, and `/adhd:push-component` all require the official Figma plugin — install it with: @@ -87,6 +88,22 @@ The scoped report covers the same rules (STRUCT001–010 + variable mismatches), The skill parses the component's TypeScript prop unions, generates a temp preview route, auto-starts the Next.js dev server if needed, captures via `generate_figma_design`, wraps the captured frames into a Component Set with variant properties, rebinds raw values to existing design-system variables, and runs the same lint engine `/adhd:lint` uses as a preflight check before finalizing. If the Cartesian product would exceed 30 variants, pass `--max-variants ` to cap with coverage-first selection. +### Pull a component + +``` +# From the consumer repo, with a mapping already established by /adhd:push-component: +/adhd:pull-component app/components/avatar/index.tsx + +# Or by Figma URL — reverse-resolves to the path via adhd.config.ts: +/adhd:pull-component https://www.figma.com/design/?node-id=91-18 + +# Pre-flight is strict by default — if Figma has unbound raw values, pull aborts and asks the designer to bind them. +# To accept hardcoded fallbacks anyway (with adhd:off-system comments for greppability): +/adhd:pull-component app/components/avatar/index.tsx --allow-unbound +``` + +The skill reads the Figma Component Set, diffs it against the React file's `Record` lookup tables, prompts on each divergence, and rewrites only those tables (plus union type members). Function body, JSX, hooks, handlers, and imports are never modified. + ### Figma file structure The Figma file must follow the structure mandated in the spec — a `Primitives` collection (no modes) and a `Semantic` collection (Light + Dark modes). The skill validates this and surfaces fix-up guidance on failure.