Skip to content

Commit ffd44aa

Browse files
committed
refactor(m3): hardening review — SFC props, CSS-var token grounding, cleanup
A retrospective pass over the M3 surface (which deviated a lot from the original plan) for more-general implementations, holding the line at "no regression". Four findings, each strictly ≥ today: 1. Vue/Svelte prop extraction (+ false-TODO safety net). The scanner only took a filename baseline for SFCs (propNames []), so the join dumped every variant axis into unmatchedProps — a false "extend this component" TODO on every reused Vue/Svelte component. Now parse the <script> block: Vue defineProps (type/object/array), Vue Options-API props, Svelte export let / $props(). ScannedComponent gains propsExtracted so the join can tell "genuinely no props" from "unknown" and suppress unmatched/matched when unknown. React path unchanged. 2. CSS-variable token grounding (token_map). When no single token config is detected (a plain :root custom-property project, or Tailwind whose @theme entry wasn't located), aggregate custom properties across the repo's CSS and let the join filter them — incidental vars stay unmatched, so this only adds real matches, never regresses. Unlocks the large class of non-Tailwind design systems that previously got no automatic token grounding. 3. Remove the cut/deferred greenfield skill stubs (figma-audit, figma-sync-tokens) per the M3 re-eval — their frontmatter advertised tools that will never exist and would mis-trigger Claude. Dropped the README cp lines and the figma-codegen handoff mention. 4. Hoist the duplicated IGNORED_DIRS into a shared module (scan / profile / the new repo-css aggregator), so a vendor/build dir is added once. Drive-by: fix a pre-existing plugin typecheck break — newer plugin-typings add nodes without an index signature, so serializer.ts's `as Record<string, unknown>` cast no longer type-checks; use a per-property cast (the file's idiom). 546 tests (+9), typecheck + build green.
1 parent 838043b commit ffd44aa

16 files changed

Lines changed: 459 additions & 103 deletions

File tree

packages/plugin/src/serializer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,15 @@ const enrichWithMixins = (node: SceneNode, base: SerializedNode): SerializedNode
206206
// table row dividers, underline inputs and top-accent rules are all per-side, and
207207
// collapsing to a single "mixed" loses which edges actually have a stroke.
208208
out.strokeWeight = MIXED;
209-
const n = node as Record<string, unknown>;
209+
// Per-property cast (matching this file's idiom) rather than `as Record<string, unknown>`:
210+
// newer plugin-typings include nodes (e.g. SlotNode) without an index signature, so the
211+
// record cast no longer type-checks.
212+
const n = node as {
213+
strokeTopWeight?: unknown;
214+
strokeRightWeight?: unknown;
215+
strokeBottomWeight?: unknown;
216+
strokeLeftWeight?: unknown;
217+
};
210218
const sides = {
211219
top: n.strokeTopWeight,
212220
right: n.strokeRightWeight,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Directories never worth walking when scanning a repo (components, CSS entries, token sources).
2+
// Shared so the component scanner, the profile's CSS probe, and the token aggregator stay in lockstep
3+
// — adding a build/vendor dir here covers every repo walk at once instead of drifting per copy.
4+
export const IGNORED_DIRS = new Set([
5+
'node_modules',
6+
'dist',
7+
'build',
8+
'.next',
9+
'.nuxt',
10+
'.git',
11+
'coverage',
12+
]);
13+
14+
/** True when any path segment is an ignored (vendor/build) directory. */
15+
export const isIgnoredPath = (relPath: string): boolean =>
16+
relPath.split('/').some(seg => IGNORED_DIRS.has(seg));

packages/server/src/join/component-map.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,17 @@ const statusFor = (confidence: number, threshold: number): MappingStatus => {
136136
* Split a usage's Figma axes into those the candidate already has as props (matchedProps) and those
137137
* it lacks (unmatchedProps — the component-extension TODOs). Same casefold predicate for both, so
138138
* matched ∪ unmatched == variantAxes.
139+
*
140+
* When the component's props couldn't be parsed (propsExtracted === false — a baseline scan that
141+
* didn't read props), we can't tell matched from unmatched: return both empty rather than dumping
142+
* every axis into unmatchedProps, which would otherwise report a true "extend this component" TODO
143+
* for props that very likely already exist (the case for an unparsed Vue/Svelte SFC).
139144
*/
140145
const partitionAxes = (
141146
variantAxes: readonly string[],
142147
component: ScannedComponent,
143148
): { matchedProps: string[]; unmatchedProps: string[] } => {
149+
if (!component.propsExtracted) return { matchedProps: [], unmatchedProps: [] };
144150
const codeProps = new Set(component.propNames.map(p => p.toLowerCase()));
145151
const matchedProps: string[] = [];
146152
const unmatchedProps: string[] = [];

packages/server/src/profile/profile.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { glob, readFile } from 'node:fs/promises';
22
import { join, relative, resolve } from 'node:path';
33

4+
import { isIgnoredPath } from '../ignored-dirs.js';
5+
46
// Project Profile — the structured "how this project writes code" that the join tools (component_map,
57
// token_map) switch their target side on. Detection is split in two: gatherProjectInput does the IO
68
// (reads manifests / probes for config files / scans CSS entry points) and detectProfile is a pure
@@ -96,17 +98,6 @@ const TAILWIND_CONFIGS = [
9698
/** Config files worth probing for at the project root; presence feeds styling detection. */
9799
const PROBE_CONFIG_FILES = [...TAILWIND_CONFIGS];
98100

99-
/** Directories never worth walking when scanning for CSS entry points. */
100-
const IGNORED_DIRS = new Set([
101-
'node_modules',
102-
'dist',
103-
'build',
104-
'.next',
105-
'.nuxt',
106-
'.git',
107-
'coverage',
108-
]);
109-
110101
// Tailwind v4 marks its CSS-first config inline: `@import "tailwindcss"` pulls the framework in and
111102
// `@theme { ... }` declares tokens. Either marker identifies the v4 token source.
112103
const CSS_TAILWIND_IMPORT = /@import\s+["']tailwindcss["']/;
@@ -138,7 +129,7 @@ const findTailwindCssEntry = async (root: string): Promise<string | undefined> =
138129
let scanned = 0;
139130
for await (const entry of glob('**/*.css', { cwd: root })) {
140131
const rel = typeof entry === 'string' ? entry : String(entry);
141-
if (rel.split('/').some(seg => IGNORED_DIRS.has(seg))) continue;
132+
if (isIgnoredPath(rel)) continue;
142133
if (scanned >= 200) break; // safety cap against pathological repos
143134
scanned += 1;
144135
let body: string;

packages/server/src/scan/scan.ts

Lines changed: 183 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { basename, dirname, extname, join } from 'node:path';
33

44
import { parseSync } from 'oxc-parser';
55

6+
import { isIgnoredPath } from '../ignored-dirs.js';
7+
68
// Component scanner — finds the project's existing components so component_map can join Figma names
79
// against them. The guiding principle: never pattern-match the directory layout (feature-based, atomic,
810
// flat all differ); identify a component by its *AST signature* (a PascalCase, exported, function-ish
@@ -17,22 +19,19 @@ export interface ScannedComponent {
1719
/** Repo-relative path. */
1820
filePath: string;
1921
exportKind: 'default' | 'named';
20-
/** Best-effort destructured prop names from the component's first param; [] when not extractable. */
22+
/** The component's prop names; [] when the component has none OR they couldn't be parsed. */
2123
propNames: string[];
24+
/**
25+
* Whether propNames is a real parse result (so [] means "genuinely no props") versus a baseline
26+
* that couldn't read props (so [] means "unknown"). The component join uses this to avoid
27+
* reporting every variant axis as an unmatched prop just because we never parsed the props — a
28+
* false "extend this component" TODO. True once we've parsed the source; false only on a parse
29+
* failure.
30+
*/
31+
propsExtracted: boolean;
2232
framework: ComponentFramework;
2333
}
2434

25-
/** Directories never worth walking. */
26-
const IGNORED_DIRS = new Set([
27-
'node_modules',
28-
'dist',
29-
'build',
30-
'.next',
31-
'.nuxt',
32-
'.git',
33-
'coverage',
34-
]);
35-
3635
/** React HOCs whose call wraps a component function — the binding is still a component. */
3736
const COMPONENT_WRAPPERS = new Set(['forwardRef', 'memo', 'observer']);
3837

@@ -120,23 +119,188 @@ export const extractReactComponents = (filePath: string, code: string): ScannedC
120119
filePath,
121120
exportKind: cand.exportKind,
122121
propNames: propNamesOf(cand.fn),
122+
propsExtracted: true,
123123
framework: 'react',
124124
});
125125
}
126126
return out;
127127
};
128128

129+
/* eslint-disable @typescript-eslint/no-explicit-any -- shared oxc AST walker below */
130+
131+
/** Depth-first walk of an oxc/ESTree node, yielding every CallExpression encountered. */
132+
const collectCalls = (root: any): any[] => {
133+
const out: any[] = [];
134+
const visit = (node: any): void => {
135+
if (node === null || typeof node !== 'object') return;
136+
if (node.type === 'CallExpression') out.push(node);
137+
for (const key of Object.keys(node)) {
138+
const v = (node as Record<string, unknown>)[key];
139+
if (Array.isArray(v)) for (const c of v) visit(c);
140+
else if (v !== null && typeof v === 'object') visit(v);
141+
}
142+
};
143+
visit(root);
144+
return out;
145+
};
146+
147+
/** Prop names from a `defineProps` call: a type literal, an object, or an array of string keys. */
148+
const definePropsNames = (call: any): string[] => {
149+
// Type form: defineProps<{ size?: string; variant: 'a' | 'b' }>(). oxc exposes the instantiation as
150+
// typeArguments (older trees: typeParameters); its first param is a TSTypeLiteral whose members'
151+
// keys are the prop names.
152+
const typeArgs = call.typeArguments ?? call.typeParameters;
153+
const typeLiteral = typeArgs?.params?.[0];
154+
if (typeLiteral?.type === 'TSTypeLiteral') {
155+
return (typeLiteral.members ?? [])
156+
.map((m: any) => m?.key?.name)
157+
.filter((n: unknown): n is string => typeof n === 'string');
158+
}
159+
const arg0 = call.arguments?.[0];
160+
// Object form: defineProps({ size: String, variant: { type: String } }) → keys.
161+
if (arg0?.type === 'ObjectExpression') {
162+
return (arg0.properties ?? [])
163+
.map((p: any) => p?.key?.name ?? p?.key?.value)
164+
.filter((n: unknown): n is string => typeof n === 'string');
165+
}
166+
// Array form: defineProps(['size', 'variant']) → the string literals.
167+
if (arg0?.type === 'ArrayExpression') {
168+
return (arg0.elements ?? [])
169+
.map((e: any) => (e?.type === 'Literal' ? e.value : undefined))
170+
.filter((n: unknown): n is string => typeof n === 'string');
171+
}
172+
return [];
173+
};
174+
175+
/** Names from a `props: { … } | [ … ]` member of an object (Vue Options API props declaration). */
176+
const propsMemberNames = (obj: any): string[] => {
177+
const propsProp = (obj?.properties ?? []).find(
178+
(p: any) => (p?.key?.name ?? p?.key?.value) === 'props',
179+
);
180+
const value = propsProp?.value;
181+
if (value?.type === 'ObjectExpression') {
182+
return (value.properties ?? [])
183+
.map((p: any) => p?.key?.name ?? p?.key?.value)
184+
.filter((n: unknown): n is string => typeof n === 'string');
185+
}
186+
if (value?.type === 'ArrayExpression') {
187+
return (value.elements ?? [])
188+
.map((e: any) => (e?.type === 'Literal' ? e.value : undefined))
189+
.filter((n: unknown): n is string => typeof n === 'string');
190+
}
191+
return [];
192+
};
193+
194+
/**
195+
* Vue Options API prop names: `export default { props: { … } }` — or wrapped in defineComponent /
196+
* defineNuxtComponent. The default export is either an object or a call whose first arg is the
197+
* object.
198+
*/
199+
const vueOptionsPropsNames = (program: any): string[] => {
200+
for (const node of program.body ?? []) {
201+
if (node.type !== 'ExportDefaultDeclaration') continue;
202+
const d = node.declaration;
203+
const obj = d?.type === 'ObjectExpression' ? d : d?.arguments?.[0];
204+
if (obj?.type === 'ObjectExpression') return propsMemberNames(obj);
205+
}
206+
return [];
207+
};
208+
209+
/** Svelte prop names: `export let foo` (Svelte 4) and `let { a, b } = $props()` (Svelte 5 runes). */
210+
const sveltePropNames = (program: any): string[] => {
211+
const names = new Set<string>();
212+
for (const node of program.body ?? []) {
213+
if (
214+
node.type === 'ExportNamedDeclaration' &&
215+
node.declaration?.type === 'VariableDeclaration' &&
216+
node.declaration.kind === 'let'
217+
) {
218+
for (const d of node.declaration.declarations ?? [])
219+
if (d?.id?.type === 'Identifier' && typeof d.id.name === 'string') names.add(d.id.name);
220+
}
221+
}
222+
// Svelte 5 runes: `let { a, b } = $props()` — a destructuring declarator initialized by $props().
223+
for (const node of program.body ?? []) {
224+
if (node.type !== 'VariableDeclaration') continue;
225+
for (const d of node.declarations ?? []) {
226+
if (d?.init?.type === 'CallExpression' && d.init.callee?.name === '$props') {
227+
for (const p of d.id?.properties ?? [])
228+
if (p?.key?.name && typeof p.key.name === 'string') names.add(p.key.name);
229+
}
230+
}
231+
}
232+
return [...names];
233+
};
234+
129235
/* eslint-enable @typescript-eslint/no-explicit-any */
130236

131237
const REACT_EXTS = new Set(['.tsx', '.jsx']);
132238

133-
/** Baseline extractor for SFC frameworks: the file is the component, name derived from the path. */
134-
const extractSfcComponent = (
239+
// Pull every <script> / <script setup> body out of an SFC. lang="ts" (or its absence) decides the
240+
// parser dialect; a .vue/.svelte file with no script block is a genuinely prop-less template.
241+
const SCRIPT_BLOCK = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
242+
243+
interface ScriptBlock {
244+
body: string;
245+
ts: boolean;
246+
}
247+
248+
const extractScriptBlocks = (code: string): ScriptBlock[] => {
249+
const out: ScriptBlock[] = [];
250+
for (const m of code.matchAll(SCRIPT_BLOCK)) {
251+
const attrs = m[1] ?? '';
252+
out.push({ body: m[2] ?? '', ts: /\blang=["']ts["']/.test(attrs) });
253+
}
254+
return out;
255+
};
256+
257+
/**
258+
* Extract a single-file component (Vue / Svelte). The file is the component (its name is the file
259+
* by convention); props come from the <script> block — Vue's defineProps (type / object / array
260+
* forms) and Options-API `props`, Svelte's `export let` / `$props()`.
261+
*
262+
* PropsExtracted distinguishes "[] = genuinely no props" from "[] = unknown" so the join won't
263+
* invent extension TODOs. It's true only when we either found props, or the file is a script-less
264+
* (so genuinely prop-less) template. When a script is present but we read no props — a parse error,
265+
* or a prop-declaration style we don't recognize — it stays false (conservative: the join then
266+
* suppresses matched/unmatched rather than asserting prop gaps we can't actually see). oxc doesn't
267+
* throw on bad input, so a parse failure surfaces here simply as "no props found".
268+
*/
269+
export const extractSfcComponent = (
135270
filePath: string,
271+
code: string,
136272
framework: ComponentFramework,
137-
): ScannedComponent[] => [
138-
{ name: nameFromFile(filePath), filePath, exportKind: 'default', propNames: [], framework },
139-
];
273+
): ScannedComponent[] => {
274+
const base = {
275+
name: nameFromFile(filePath),
276+
filePath,
277+
exportKind: 'default' as const,
278+
framework,
279+
};
280+
const scripts = extractScriptBlocks(code);
281+
if (scripts.length === 0) return [{ ...base, propNames: [], propsExtracted: true }];
282+
283+
const names = new Set<string>();
284+
for (const script of scripts) {
285+
let program: ReturnType<typeof parseSync>['program'];
286+
try {
287+
// Name the virtual source so oxc picks the right dialect (TS enables defineProps<...>()).
288+
program = parseSync(`sfc.${script.ts ? 'ts' : 'js'}`, script.body).program;
289+
} catch {
290+
continue;
291+
}
292+
if (framework === 'vue') {
293+
for (const call of collectCalls(program))
294+
if ((call as { callee?: { name?: string } }).callee?.name === 'defineProps')
295+
for (const n of definePropsNames(call)) names.add(n);
296+
for (const n of vueOptionsPropsNames(program)) names.add(n);
297+
} else {
298+
for (const n of sveltePropNames(program)) names.add(n);
299+
}
300+
}
301+
// Found props → confidently extracted. None found despite a script → unknown (don't claim).
302+
return [{ ...base, propNames: [...names], propsExtracted: names.size > 0 }];
303+
};
140304

141305
const frameworkForExt = (ext: string): ComponentFramework | null => {
142306
if (REACT_EXTS.has(ext)) return 'react';
@@ -164,7 +328,7 @@ export const scanComponents = async (
164328

165329
for await (const entry of glob(patterns, { cwd: rootDir })) {
166330
const rel = typeof entry === 'string' ? entry : String(entry);
167-
if (rel.split('/').some(seg => IGNORED_DIRS.has(seg))) continue;
331+
if (isIgnoredPath(rel)) continue;
168332
const framework = frameworkForExt(extname(rel));
169333
if (framework === null) continue;
170334
let code: string;
@@ -175,7 +339,7 @@ export const scanComponents = async (
175339
continue;
176340
}
177341
if (framework === 'react') out.push(...extractReactComponents(rel, code));
178-
else out.push(...extractSfcComponent(rel, framework));
342+
else out.push(...extractSfcComponent(rel, code, framework));
179343
}
180344
return out;
181345
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { glob, readFile } from 'node:fs/promises';
2+
import { join } from 'node:path';
3+
4+
import { isIgnoredPath } from '../ignored-dirs.js';
5+
import { parseCssCustomProperties, type ProjectToken } from './tokens.js';
6+
7+
// The token join's right-hand side when there's no single detected CSS config — i.e. a non-Tailwind
8+
// project that defines its design tokens as plain CSS custom properties (:root { --primary: … }), or
9+
// a Tailwind project whose @theme entry wasn't located. Rather than guess *which* CSS file is "the"
10+
// token source (no reliable marker exists, unlike Tailwind's @import/@theme), aggregate the custom
11+
// properties from every hand-authored CSS file and let token_map's join filter them: a Figma variable
12+
// only surfaces a candidate when name- or value-match agrees, so incidental vars (--header-height,
13+
// reset rules) sit in the pool unmatched and never reach the output. Worst case the pool matches
14+
// nothing and the result is identical to today's empty join — so the fallback can't regress.
15+
16+
const MAX_CSS_FILES = 200; // safety cap against pathological repos
17+
18+
export interface AggregatedCss {
19+
tokens: ProjectToken[];
20+
/** Repo-relative CSS files that contributed at least one custom property. */
21+
files: string[];
22+
}
23+
24+
/**
25+
* Walk every CSS file in the repo (skipping vendored/build dirs), parse its custom properties, and
26+
* pool them. Tokens are kept as-is (no cross-file de-dup): the join prefers an exact value-match,
27+
* so a name collision across files resolves to the right-valued token when the Figma side carries a
28+
* hex.
29+
*/
30+
export const aggregateRepoCssTokens = async (rootDir: string): Promise<AggregatedCss> => {
31+
const tokens: ProjectToken[] = [];
32+
const files: string[] = [];
33+
let scanned = 0;
34+
35+
for await (const entry of glob('**/*.css', { cwd: rootDir })) {
36+
const rel = typeof entry === 'string' ? entry : String(entry);
37+
if (isIgnoredPath(rel)) continue;
38+
if (scanned >= MAX_CSS_FILES) break;
39+
scanned += 1;
40+
let body: string;
41+
try {
42+
// eslint-disable-next-line no-await-in-loop -- sequential repo walk; clarity over batching
43+
body = await readFile(join(rootDir, rel), 'utf8');
44+
} catch {
45+
continue;
46+
}
47+
const parsed = parseCssCustomProperties(body);
48+
if (parsed.length > 0) {
49+
tokens.push(...parsed);
50+
files.push(rel);
51+
}
52+
}
53+
return { tokens, files };
54+
};

0 commit comments

Comments
 (0)