diff --git a/figma-limitless-mcp/AGENTS.md b/figma-limitless-mcp/AGENTS.md index b5a89bf..6e3260e 100644 --- a/figma-limitless-mcp/AGENTS.md +++ b/figma-limitless-mcp/AGENTS.md @@ -47,7 +47,7 @@ structure right?" — no network, no Figma AI credits. `server/src/skills.ts`: `list_skills` (catalog), `read_skill(slug)` (full doc, whitelisted slug — no path traversal), `get_build_recipe(step?)` (the canonical Primitive→Semantic→Component order + the step's **actionable lint gate**). -- **Linter** — `lint_design_system` runs 51/57 detectors over a `LintSnapshot`. The +- **Linter** — `lint_design_system` runs all 57/57 catalog detectors over a `LintSnapshot`. The plugin's `lint_run` gathers the snapshot (variable graph + styles + components + node bindings, after `loadAllPagesAsync()`) — plus **per-component enrichment** from one bounded DFS (20k-node budget + `componentScanTruncated`): TEXT-style @@ -59,8 +59,10 @@ structure right?" — no network, no Figma AI credits. `(snap, config?) => PartialFinding[]` in `server/src/lint/detectors/.ts`, registered into the `DETECTORS` map via the `detectors/register.ts` side-effect that `lint/index.ts` imports. Enrichment-driven detectors degrade to silent when - their fields are absent (old plugin build). Remaining 6 rules are deferred - (instance-override / frame-fingerprint / exportAsync / Code-Connect data). + their fields are absent (old plugin build). The catalog is now fully + implemented — the last wave added instance-restyle / dev-resource / + detached-frame gathers and reframed contrast-fallback as an offline + translucency check. Opinionated/house-style rules ship `defaultOn:false`. **The closed loop:** `get_build_recipe(step)` → build that tier → run the gate's `run` call (`lint_design_system {only:[…]}`) → fix `severity:error` findings → diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index 50ffb92..7a5e1f4 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -4644,6 +4644,27 @@ const handleRequest = async ( }; }; + // Wave 11b — Rule 2 (component-set-has-code-mapping): one + // getDevResourcesAsync round-trip per COMPONENT_SET, batched + hard-capped + // (dozens of sets, but bound the async fan-out). Errors/uncapped -> undefined + // -> the detector skips that set (never a false flag). + const MAX_CHILD_TYPES = 40; + const MAX_CODE_MAPPING_CHECKS = 120; + const setsForMapping = componentNodes.filter((n) => n.type === "COMPONENT_SET"); + const checkedSets = setsForMapping.slice(0, MAX_CODE_MAPPING_CHECKS); + const codeMappingScanTruncated = setsForMapping.length > checkedSets.length; + const mappingEntries = await Promise.all( + checkedSets.map(async (n): Promise<[string, boolean | undefined]> => { + try { + const res = await (n as ComponentSetNode).getDevResourcesAsync(); + return [n.id, Array.isArray(res) && res.length > 0]; + } catch { + return [n.id, undefined]; + } + }) + ); + const mappingById = new Map(mappingEntries); + const snapComponents = componentNodes.map((n) => { // A variant COMPONENT (child of a COMPONENT_SET) throws on // componentPropertyDefinitions — only sets and standalone components @@ -4664,8 +4685,19 @@ const handleRequest = async ( } catch { /* leave un-enriched */ } + // Wave 11b: standalone-component fingerprint (detached-frame match). + if (n.type === "COMPONENT") { + try { + const kids = (n as ComponentNode).children ?? []; + out.childCount = kids.length; + out.childTypeSeq = kids.slice(0, MAX_CHILD_TYPES).map((k) => k.type); + } catch { + /* no fingerprint */ + } + } // COMPONENT_SET: realized variant tuples + default variant tuple. if (n.type === "COMPONENT_SET") { + out.hasCodeMapping = mappingById.get(n.id); try { const set = n as ComponentSetNode; const tuples: Array> = []; @@ -4744,6 +4776,85 @@ const handleRequest = async ( } } + // Wave 11b — Rule 1 (no-instance-restyle-override): bounded scan of the + // largest node population. Only TOP-LEVEL instances (no INSTANCE ancestor) + // are considered, so a nested instance's restyle isn't double-reported on + // its container. Emit ONLY restyled instances (membership == the signal). + const STYLE_OVERRIDE_FIELDS = new Set([ + "fills", "strokes", "effects", "fillStyleId", "strokeStyleId", "effectStyleId", + ]); + const INSTANCE_SCAN_BUDGET = 20000; + const MAX_RESTYLED_INSTANCES = 300; + const MAX_OVERRIDE_FIELDS = 6; + const hasInstanceAncestor = (node: BaseNode & { parent?: BaseNode | null }): boolean => { + let p = node.parent; + while (p) { + if (p.type === "INSTANCE") return true; + p = (p as { parent?: BaseNode | null }).parent ?? null; + } + return false; + }; + const instances: Array<{ id: string; name: string; styleOverrideFields: string[] }> = []; + let instanceScanTruncated = false; + let instBudget = INSTANCE_SCAN_BUDGET; + for (const inst of figma.root.findAllWithCriteria({ types: ["INSTANCE"] })) { + if (instBudget <= 0 || instances.length >= MAX_RESTYLED_INSTANCES) { + instanceScanTruncated = true; + break; + } + instBudget--; + try { + if (hasInstanceAncestor(inst)) continue; // nested — its restyle belongs to the top-level instance + const fields = new Set(); + for (const o of (inst as InstanceNode).overrides ?? []) { + for (const f of o.overriddenFields ?? []) { + if (STYLE_OVERRIDE_FIELDS.has(f as string)) fields.add(f as string); + if (fields.size >= MAX_OVERRIDE_FIELDS) break; + } + if (fields.size >= MAX_OVERRIDE_FIELDS) break; + } + if (fields.size > 0) instances.push({ id: inst.id, name: inst.name, styleOverrideFields: [...fields] }); + } catch { + /* skip unreadable instance */ + } + } + + // Wave 11b — Rule 3 (detached-component-frame-signal): name-prefilter the + // FRAME population by collision with a standalone-component name (O(1) per + // frame), then fingerprint only the colliders. + const componentNameSet = new Set(); + for (const c of snapComponents) { + if (c.type === "COMPONENT" && !c.isVariant && typeof c.name === "string") { + componentNameSet.add(c.name.trim().toLowerCase()); + } + } + const FRAME_DUP_BUDGET = 20000; + const MAX_FRAME_DUPS = 300; + const frameDupCandidates: Array<{ id: string; name: string; childTypeSeq: string[]; childCount: number }> = []; + let frameDupScanTruncated = false; + if (componentNameSet.size > 0) { + let frameBudget = FRAME_DUP_BUDGET; + for (const fr of figma.root.findAllWithCriteria({ types: ["FRAME"] })) { + if (frameBudget <= 0 || frameDupCandidates.length >= MAX_FRAME_DUPS) { + frameDupScanTruncated = true; + break; + } + frameBudget--; + if (!componentNameSet.has(fr.name.trim().toLowerCase())) continue; + try { + const kids = (fr as FrameNode).children ?? []; + frameDupCandidates.push({ + id: fr.id, + name: fr.name, + childCount: kids.length, + childTypeSeq: kids.slice(0, MAX_CHILD_TYPES).map((k) => k.type), + }); + } catch { + /* skip */ + } + } + } + return { type: request.type, requestId: request.requestId, @@ -4755,6 +4866,11 @@ const handleRequest = async ( nodeBindings, bindingsTruncated, componentScanTruncated, + instances, + instanceScanTruncated, + frameDupCandidates, + frameDupScanTruncated, + codeMappingScanTruncated, meta: { pageCount: figma.root.children.length, scannedAllPages: true, diff --git a/figma-limitless-mcp/server/src/lint/config.ts b/figma-limitless-mcp/server/src/lint/config.ts index 4d288d1..a5c47af 100644 --- a/figma-limitless-mcp/server/src/lint/config.ts +++ b/figma-limitless-mcp/server/src/lint/config.ts @@ -90,6 +90,21 @@ export const RULE_CONFIG: Record = { }; }, }, + "multi-brand-alias-discipline": { + configShape: "{ brandPrefix: string (required, e.g. 'brand'), roles?: string[] (default ['accent','action','brand','primary']) }", + defaults: null, // config REQUIRED — the brand layer must be declared + resolve: (raw) => { + if (!isObj(raw)) throw new LintConfigError("multi-brand-alias-discipline", "needs config { brandPrefix: string }"); + if (typeof raw.brandPrefix !== "string" || raw.brandPrefix.length === 0) { + throw new LintConfigError("multi-brand-alias-discipline", "'brandPrefix' must be a non-empty string"); + } + const roles = + raw.roles == null + ? ["accent", "action", "brand", "primary"] + : asStringArray(raw.roles, "multi-brand-alias-discipline", "roles"); + return { brandPrefix: raw.brandPrefix, roles }; + }, + }, "semantic-role-allowlist": { configShape: "{ allowlist: string[] (required, non-empty), resolvedType?: 'COLOR'|'FLOAT'|'STRING'|'BOOLEAN' (default COLOR) }", defaults: null, // config REQUIRED — no universal role vocabulary diff --git a/figma-limitless-mcp/server/src/lint/detectors/a11y.ts b/figma-limitless-mcp/server/src/lint/detectors/a11y.ts index d045676..8a8a681 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/a11y.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/a11y.ts @@ -45,6 +45,30 @@ function resolveColor( return null; } +/** Resolve to the raw alpha of a COLOR variable in a mode (1 if opaque/absent). */ +function resolveAlpha( + a: ReturnType, + varId: string, + mode: string, + depth = 0 +): number | null { + if (depth > 16) return null; + const v = a.byId.get(varId); + if (!v || v.resolvedType !== "COLOR") return null; + const val = mode in v.valuesByMode ? v.valuesByMode[mode] : Object.values(v.valuesByMode)[0]; + const target = aliasTarget(val); + if (target) { + const tv = a.byId.get(target); + const tMode = tv && mode in tv.valuesByMode ? mode : tv ? Object.keys(tv.valuesByMode)[0] : mode; + return resolveAlpha(a, target, tMode, depth + 1); + } + if (val && typeof val === "object" && "r" in (val as object)) { + const o = val as { a?: number }; + return typeof o.a === "number" ? o.a : 1; + } + return null; +} + const luminance = ({ r, g, b }: RGB): number => { const lin = (c: number): number => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); @@ -188,8 +212,37 @@ const minFontSize: Detector = (snap, config) => { return out; }; +// A semantic fg/bg/line token that resolves to a TRANSLUCENT paint (alpha < 1): +// the alias-resolved RGB contrast can't be trusted (it composites over whatever +// backdrop it's placed on), so the fg-bg-pair/graphical-contrast numbers are +// unreliable for it — pixel-sample the rendered result instead. info; pure over +// the existing colour values (alpha is already in the snapshot). +const A11Y_ROLE = /^(fg|foreground|text|ink|content|icon|label|bg|background|surface|fill|border|stroke|outline|divider|separator|ring)$/; +const contrastFallbackExportSampling: Detector = (snap) => { + const a = analyze(snap); + const out: PartialFinding[] = []; + for (const v of a.variables) { + if (v.tier !== "semantic" || v.resolvedType !== "COLOR") continue; + if (!A11Y_ROLE.test(v.name.toLowerCase().split("/")[0])) continue; + const modes = a.modesByCollection.get(v.collectionId) ?? Object.keys(v.valuesByMode); + for (const mode of modes) { + const alpha = resolveAlpha(a, v.id, mode); + if (alpha !== null && alpha < 0.999) { + out.push({ + rule_id: "contrast-fallback-export-sampling", + variableId: v.id, + message: `'${v.name}' resolves to a translucent colour (alpha ${alpha.toFixed(2)}) in mode ${mode}; alias-resolved contrast can't be trusted for it — verify with pixel sampling over the real backdrop.`, + }); + break; + } + } + } + return out; +}; + export const a11yDetectors: Record = { "fg-bg-pair-contrast": fgBgPairContrast, "border-icon-graphical-contrast": borderIconGraphicalContrast, "min-font-size": minFontSize, + "contrast-fallback-export-sampling": contrastFallbackExportSampling, }; diff --git a/figma-limitless-mcp/server/src/lint/detectors/codegen.ts b/figma-limitless-mcp/server/src/lint/detectors/codegen.ts index fa7053d..8e86eab 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/codegen.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/codegen.ts @@ -142,6 +142,31 @@ const textLayerUsesStyleOrBoundType: Detector = (snap) => { return out; }; +// Wave 11b — opt-in (defaultOn:false). hasCodeMapping is sourced from the +// plugin's getDevResourcesAsync, which returns ANY Dev Mode dev resource (a +// Storybook/Jira/docs link), NOT Code Connect specifically — so this is a weak +// "is this set linked to its code?" proxy, kept off by default and honestly +// worded. CONDITIONAL on adoption: only flag once SOME set carries a dev +// resource. Sets past the async cap are undefined and excluded from both sides. +const componentSetHasCodeMapping: Detector = (snap) => { + const sets = (snap.components ?? []).filter( + (c) => c.type === "COMPONENT_SET" && typeof c.hasCodeMapping === "boolean" + ); + if (sets.length === 0) return []; // old plugin / none checked + if (!sets.some((c) => c.hasCodeMapping === true)) return []; // none linked — nothing to compare against + const out: PartialFinding[] = []; + for (const c of sets) { + if (c.hasCodeMapping === false) { + out.push({ + rule_id: "component-set-has-code-mapping", + nodeId: c.id, + message: `Component set '${c.name}' has no Dev Mode dev resource (code/story/docs link), but other sets do; attach one so its implementation is discoverable. (Checks dev-resource presence, not Code Connect specifically.)`, + }); + } + } + return out; +}; + export const codegenDetectors: Record = { "published-variable-has-codesyntax-web": publishedVariableHasCodesyntaxWeb, "codesyntax-web-unique": codesyntaxWebUnique, @@ -149,4 +174,5 @@ export const codegenDetectors: Record = { "codesyntax-web-matches-name": codesyntaxWebMatchesName, "no-raw-value-on-component-node": noRawValueOnComponentNode, "text-layer-uses-style-or-bound-type": textLayerUsesStyleOrBoundType, + "component-set-has-code-mapping": componentSetHasCodeMapping, }; diff --git a/figma-limitless-mcp/server/src/lint/detectors/components.ts b/figma-limitless-mcp/server/src/lint/detectors/components.ts index b13fb7a..c2e4825 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/components.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/components.ts @@ -250,6 +250,86 @@ const variantMatrixComplete: Detector = (snap) => { return out; }; +// Opt-in (defaultOn:false): the "base" per axis is a house convention. Uses the +// enrichment's defaultVariantTuple + each axis's first-authored option as base +// (Figma preserves option order). Flags a default variant that isn't the base +// tuple, so new instances start from the neutral/documented state. +const defaultVariantIsBaseTuple: Detector = (snap) => { + const out: PartialFinding[] = []; + for (const c of snap.components ?? []) { + if (c.type !== "COMPONENT_SET" || !c.defaultVariantTuple) continue; + const defs = c.propertyDefinitions; + if (!defs || typeof defs !== "object") continue; + const deviations: string[] = []; + for (const [key, raw] of Object.entries(defs)) { + const def = (raw ?? {}) as PropDef; + if (def.type !== "VARIANT" || !Array.isArray(def.variantOptions) || def.variantOptions.length === 0) continue; + const axis = baseName(key); + const base = def.variantOptions[0]; + const actual = c.defaultVariantTuple[axis]; + if (actual !== undefined && actual !== base) deviations.push(`${axis}=${actual} (base '${base}')`); + } + if (deviations.length > 0) { + out.push({ + rule_id: "default-variant-is-base-tuple", + nodeId: c.id, + message: `Component set '${c.name}' default variant isn't the base tuple: ${deviations.join(", ")}; set the default to each axis's first/base option so new instances start neutral.`, + }); + } + } + return out; +}; + +// Wave 11b (needs the instance/frame gather). Both degrade to silent on an old +// plugin. Only restyled instances are emitted, so the array IS the finding set; +// scan truncation causes false negatives only, never a false positive. +const noInstanceRestyleOverride: Detector = (snap) => { + if (!Array.isArray(snap.instances)) return []; + const out: PartialFinding[] = []; + for (const inst of snap.instances) { + if (inst.styleOverrideFields.length > 0) { + out.push({ + rule_id: "no-instance-restyle-override", + nodeId: inst.id, + message: `Instance '${inst.name}' carries style overrides (${inst.styleOverrideFields.join(", ")}); move the styling into the source component (a variant/token) and reset the override so it stays in sync.`, + }); + } + } + return out; +}; + +const normName = (s: string): string => s.trim().toLowerCase(); +// Opt-in (defaultOn:false), info: a FRAME that shares BOTH a component's +// normalized name AND its exact direct-child type sequence is likely a +// detached/hand-rebuilt copy. Both fingerprints must be COMPLETE (childCount === +// childTypeSeq.length) so a capped sequence never yields a partial-prefix match. +const detachedComponentFrameSignal: Detector = (snap) => { + if (!snap.frameDupCandidates || snap.frameDupCandidates.length === 0) return []; + const byName = new Map>(); + for (const c of snap.components ?? []) { + if (c.type !== "COMPONENT" || c.isVariant) continue; + if (!Array.isArray(c.childTypeSeq) || typeof c.childCount !== "number") continue; + if (c.childCount !== c.childTypeSeq.length) continue; // truncated fingerprint — skip + const key = normName(c.name); + (byName.get(key) ?? byName.set(key, []).get(key)!).push({ name: c.name, seq: c.childTypeSeq, count: c.childCount }); + } + const out: PartialFinding[] = []; + for (const f of snap.frameDupCandidates) { + if (f.childCount !== f.childTypeSeq.length) continue; // frame fingerprint truncated — skip + for (const c of byName.get(normName(f.name)) ?? []) { + if (c.count === f.childCount && c.seq.every((t, i) => t === f.childTypeSeq[i])) { + out.push({ + rule_id: "detached-component-frame-signal", + nodeId: f.id, + message: `Frame '${f.name}' has the same name and direct-child structure as component '${c.name}'; it looks like a detached/hand-rebuilt copy — replace it with an instance, or confirm it's intentionally bespoke.`, + }); + break; + } + } + } + return out; +}; + export const componentDetectors: Record = { "property-name-convention-unique": propertyNameConventionUnique, "boolean-vocab-variant-should-be-boolean": booleanVocabVariantShouldBeBoolean, @@ -260,4 +340,7 @@ export const componentDetectors: Record = { "shared-property-value-consistency": sharedPropertyValueConsistency, "no-dead-component-property": noDeadComponentProperty, "variant-matrix-complete": variantMatrixComplete, + "default-variant-is-base-tuple": defaultVariantIsBaseTuple, + "no-instance-restyle-override": noInstanceRestyleOverride, + "detached-component-frame-signal": detachedComponentFrameSignal, }; diff --git a/figma-limitless-mcp/server/src/lint/detectors/theming.ts b/figma-limitless-mcp/server/src/lint/detectors/theming.ts index faa9c27..8e841a7 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/theming.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/theming.ts @@ -4,7 +4,7 @@ import type { Detector } from "../runner.js"; import type { PartialFinding } from "./shared.js"; -import { analyze } from "./shared.js"; +import { analyze, aliasTarget } from "./shared.js"; const primitiveComponentSingleMode: Detector = (snap) => { const a = analyze(snap); @@ -115,6 +115,62 @@ const oneThemeAxisPerCollection: Detector = (snap) => { return out; }; +// Opt-in (defaultOn:false), config-driven: only meaningful for multi-brand DSs, +// which must declare their brand layer. A "brandable" semantic (accent/action/ +// brand/... role) should alias THROUGH a brand/* token so re-branding is a +// single-layer swap; if its alias chain reaches a primitive without passing a +// brand-layer token, flag it. Requires config { brandPrefix, roles? }. +interface MultiBrandConfig { + brandPrefix: string; + roles: string[]; +} +const multiBrandAliasDiscipline: Detector = (snap, config) => { + const cfg = config as MultiBrandConfig | undefined; + if (!cfg || typeof cfg.brandPrefix !== "string" || !cfg.brandPrefix) return []; + const prefix = cfg.brandPrefix.toLowerCase(); + const roles = new Set((cfg.roles ?? []).map((r) => r.toLowerCase())); + const a = analyze(snap); + const isBrandVar = (name: string): boolean => + name.toLowerCase().split("/").includes(prefix); + const out: PartialFinding[] = []; + for (const v of a.variables) { + if (v.tier !== "semantic") continue; + if (isBrandVar(v.name)) continue; // the brand layer itself needn't route through itself + if (!roles.has(v.name.toLowerCase().split("/")[0])) continue; + // Does the token alias through the brand layer in ANY mode? Follow each + // mode's chain in THAT SAME mode (falling back to a target's first mode only + // when the mode is absent) — collapsing to the first mode makes the result + // order-dependent and can fabricate/miss a finding on per-mode-divergent chains. + let routed = false; + for (const [modeId, val] of Object.entries(v.valuesByMode)) { + let t = aliasTarget(val); + const seen = new Set(); + let hops = 0; + while (t && !seen.has(t) && hops < 16) { + seen.add(t); + const tv = a.byId.get(t); + if (!tv) break; + if (isBrandVar(tv.name)) { + routed = true; + break; + } + const next = modeId in tv.valuesByMode ? tv.valuesByMode[modeId] : Object.values(tv.valuesByMode)[0]; + t = aliasTarget(next); + hops++; + } + if (routed) break; + } + if (!routed) { + out.push({ + rule_id: "multi-brand-alias-discipline", + variableId: v.id, + message: `Brandable semantic '${v.name}' doesn't route through the '${cfg.brandPrefix}' layer; alias it via a ${cfg.brandPrefix}/* token so re-branding is a single-layer swap.`, + }); + } + } + return out; +}; + export const themingDetectors: Record = { "primitive-component-single-mode": primitiveComponentSingleMode, "every-mode-populated": everyModePopulated, @@ -122,4 +178,5 @@ export const themingDetectors: Record = { "semantic-default-mode-is-base": semanticDefaultModeIsBase, "consistent-mode-names-across-axis": consistentModeNamesAcrossAxis, "one-theme-axis-per-collection": oneThemeAxisPerCollection, + "multi-brand-alias-discipline": multiBrandAliasDiscipline, }; diff --git a/figma-limitless-mcp/server/src/lint/registry.ts b/figma-limitless-mcp/server/src/lint/registry.ts index f0755fd..29af612 100644 --- a/figma-limitless-mcp/server/src/lint/registry.ts +++ b/figma-limitless-mcp/server/src/lint/registry.ts @@ -232,11 +232,12 @@ export const RULES: RuleMeta[] = [ }, { id: "component-set-has-code-mapping", - title: "Component sets should have a Code Connect mapping matching their props", + title: "Component sets should link to their code (a Dev Mode dev resource)", category: "code-output", severity: "warn", + defaultOn: false, // dev-resource presence is a weak proxy (not Code Connect) — opt-in skillUri: "skill://design-system/design-to-code-correctness", - fixHint: "Create/update the Code Connect mapping via set_code_mapping so its props match componentPropertyDefinitions exactly.", + fixHint: "Attach a Dev Mode dev resource linking the set to its implementation (code/story/docs) so it's discoverable. (This checks dev-resource presence, not Code Connect specifically.)", }, { id: "primitive-component-single-mode", @@ -291,6 +292,7 @@ export const RULES: RuleMeta[] = [ title: "Multi-brand tokens must alias through the brand layer", category: "theming", severity: "warn", + defaultOn: false, // only meaningful for multi-brand DSs — opt-in + configured skillUri: "skill://design-system/theming-with-modes", fixHint: "Route accent/action semantics through a brand/* token so re-branding is a single-layer swap.", }, @@ -439,6 +441,7 @@ export const RULES: RuleMeta[] = [ title: "Flag frames that duplicate a component's structure", category: "components", severity: "info", + defaultOn: false, // inherently heuristic/noisy — opt-in skillUri: "skill://design-system/component-variant-structure-for-codegen", fixHint: "Replace the duplicated frame with an instance of the component, or confirm it is intentionally bespoke.", }, @@ -447,6 +450,7 @@ export const RULES: RuleMeta[] = [ title: "Default variant must be the neutral base tuple", category: "components", severity: "warn", + defaultOn: false, // "base" per axis is a house convention — opt-in skillUri: "skill://design-system/component-variant-structure-for-codegen", fixHint: "Reorder/assign the default variant so each axis sits at its documented base value.", }, diff --git a/figma-limitless-mcp/server/src/lint/runner.ts b/figma-limitless-mcp/server/src/lint/runner.ts index c3e2bb8..86e6f95 100644 --- a/figma-limitless-mcp/server/src/lint/runner.ts +++ b/figma-limitless-mcp/server/src/lint/runner.ts @@ -59,6 +59,10 @@ export interface SnapComponent { variantTuples?: Array>; // each child variant's property -> value (capped) variantTuplesTruncated?: boolean; defaultVariantTuple?: Record; + // Wave 11b: + hasCodeMapping?: boolean; // COMPONENT_SET only: getDevResourcesAsync non-empty. undefined => not checked (old plugin / past cap / errored) + childTypeSeq?: string[]; // standalone COMPONENT only: direct-child types, capped + childCount?: number; // standalone COMPONENT only: full direct-child count } /** One node->variable binding edge (a node field bound to a variable). */ @@ -70,6 +74,23 @@ export interface SnapBinding { variableId: string; } +/** One INSTANCE carrying a STYLE override vs its main (Wave 11b). Only restyled + * instances are emitted, so array membership IS the per-instance signal. */ +export interface SnapInstance { + id: string; + name: string; + styleOverrideFields: string[]; // deduped style fields overridden, capped +} + +/** A FRAME whose normalized name collides with a standalone component's name, + * plus its direct-child fingerprint (the cheap detached-copy signal). */ +export interface SnapFrameDup { + id: string; + name: string; + childTypeSeq: string[]; // direct-child node types, capped + childCount: number; // full direct-child count (> childTypeSeq.length when capped => incomplete) +} + export interface LintSnapshot { collections: SnapCollection[]; variables: SnapVariable[]; @@ -77,6 +98,15 @@ export interface LintSnapshot { components: SnapComponent[]; nodeBindings?: SnapBinding[]; bindingsTruncated?: boolean; + componentScanTruncated?: boolean; + // Wave 11b. A NEW plugin always emits instances/frameDupCandidates keys (even + // []); an OLD plugin omits them — so `undefined` = not-gathered => detector + // silent, while `[]` = scanned-clean => pass. + instances?: SnapInstance[]; + instanceScanTruncated?: boolean; + frameDupCandidates?: SnapFrameDup[]; + frameDupScanTruncated?: boolean; + codeMappingScanTruncated?: boolean; meta: { pageCount: number; scannedAllPages: boolean }; } diff --git a/figma-limitless-mcp/server/test/wave11-rules.test.mjs b/figma-limitless-mcp/server/test/wave11-rules.test.mjs new file mode 100644 index 0000000..34efac9 --- /dev/null +++ b/figma-limitless-mcp/server/test/wave11-rules.test.mjs @@ -0,0 +1,158 @@ +// Wave 11a: the two deferred rules implementable offline (both opt-in). Each +// fires only when enabled + (for multi-brand) configured, and is silent by +// default / on its control. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { runLint } from "../dist/lint/index.js"; + +const base = (over) => ({ collections: [], variables: [], styles: [], components: [], meta: { pageCount: 1, scannedAllPages: true }, ...over }); +const has = (rep, id) => rep.findings.some((f) => f.rule_id === id); +const V = (id, name, collectionId, valuesByMode) => ({ + id, name, collectionId, resolvedType: "COLOR", + scopes: ["ALL_FILLS"], hiddenFromPublishing: false, codeSyntax: {}, description: "", + valuesByMode, +}); +const A = (id) => ({ alias: id }); + +// ---- default-variant-is-base-tuple --------------------------------------- +const setDefault = (defaultVal) => base({ + components: [{ + id: "s", name: "Button", type: "COMPONENT_SET", + propertyDefinitions: { Size: { type: "VARIANT", variantOptions: ["sm", "md", "lg"] } }, + defaultVariantTuple: { Size: defaultVal }, + }], +}); + +test("default-variant-is-base-tuple: opt-in; fires when default != first option", () => { + // opt-in off by default -> silent even though md != sm + assert.ok(!has(runLint(setDefault("md")), "default-variant-is-base-tuple")); + // enabled + default 'md' (2nd) deviates from base 'sm' -> fires + assert.ok(has(runLint(setDefault("md"), { enable: ["default-variant-is-base-tuple"] }), "default-variant-is-base-tuple")); + // enabled + default 'sm' (first/base) -> silent + assert.ok(!has(runLint(setDefault("sm"), { enable: ["default-variant-is-base-tuple"] }), "default-variant-is-base-tuple")); +}); + +// ---- multi-brand-alias-discipline ---------------------------------------- +const brandSnap = () => base({ + collections: [ + { id: "P", name: "Primitives", defaultModeId: "p", modes: [{ modeId: "p", name: "V" }] }, + { id: "S", name: "Semantic", defaultModeId: "s", modes: [{ modeId: "s", name: "V" }] }, + ], + variables: [ + { ...V("p_blue", "blue/500", "P", { p: { r: 0.1, g: 0.3, b: 0.9 } }), hiddenFromPublishing: true }, + V("brand_primary", "brand/primary", "S", { s: A("p_blue") }), // the brand layer + V("accent_good", "accent/default", "S", { s: A("brand_primary") }), // routes through brand -> ok + V("accent_bad", "accent/emphasis", "S", { s: A("p_blue") }), // skips brand -> fires + V("fg", "foreground/default", "S", { s: A("p_blue") }), // not a brandable role -> silent + ], +}); + +test("multi-brand-alias-discipline: opt-in + config; flags brandable semantics that skip the brand layer", () => { + const cfg = { config: { "multi-brand-alias-discipline": { brandPrefix: "brand" } }, enable: ["multi-brand-alias-discipline"] }; + const rep = runLint(brandSnap(), cfg); + assert.ok(rep.findings.some((f) => f.rule_id === "multi-brand-alias-discipline" && f.variableId === "accent_bad")); + assert.ok(!rep.findings.some((f) => f.rule_id === "multi-brand-alias-discipline" && f.variableId === "accent_good")); + assert.ok(!rep.findings.some((f) => f.rule_id === "multi-brand-alias-discipline" && f.variableId === "fg")); + assert.ok(!rep.findings.some((f) => f.rule_id === "multi-brand-alias-discipline" && f.variableId === "brand_primary")); // brand layer itself exempt +}); + +test("multi-brand-alias-discipline: off by default; missing config -> config_errors", () => { + assert.ok(!has(runLint(brandSnap()), "multi-brand-alias-discipline")); // opt-in off + const rep = runLint(brandSnap(), { enable: ["multi-brand-alias-discipline"] }); // enabled, no config + assert.ok(rep.config_errors.some((e) => e.rule_id === "multi-brand-alias-discipline")); + assert.ok(!has(rep, "multi-brand-alias-discipline")); +}); + +// ---- contrast-fallback-export-sampling (offline: alpha detection) --------- +const RGBA = (r, g, b, a) => ({ r, g, b, a }); +test("contrast-fallback-export-sampling: flags translucent semantic colour tokens", () => { + const snap = base({ + collections: [ + { id: "P", name: "Primitives", defaultModeId: "p", modes: [{ modeId: "p", name: "V" }] }, + { id: "S", name: "Semantic", defaultModeId: "s", modes: [{ modeId: "s", name: "V" }] }, + ], + variables: [ + { ...V("p_scrim", "black/50", "P", { p: RGBA(0, 0, 0, 0.5) }), hiddenFromPublishing: true }, + { ...V("p_solid", "black", "P", { p: RGBA(0, 0, 0, 1) }), hiddenFromPublishing: true }, + V("s_overlay", "background/overlay", "S", { s: A("p_scrim") }), // translucent -> fires + V("s_fg", "foreground/default", "S", { s: A("p_solid") }), // opaque -> silent + V("s_accent", "accent/default", "S", { s: A("p_scrim") }), // role not fg/bg/line -> silent + ], + }); + const rep = runLint(snap); + assert.ok(rep.findings.some((f) => f.rule_id === "contrast-fallback-export-sampling" && f.variableId === "s_overlay")); + assert.ok(!rep.findings.some((f) => f.rule_id === "contrast-fallback-export-sampling" && f.variableId === "s_fg")); + assert.ok(!rep.findings.some((f) => f.rule_id === "contrast-fallback-export-sampling" && f.variableId === "s_accent")); +}); + +// ---- Wave 11b: the 3 gather-driven rules (silent without the new fields) ---- +test("no-instance-restyle-override: flags restyled instances, silent on old plugin", () => { + const snap = base({ instances: [ + { id: "i1", name: "Button/primary", styleOverrideFields: ["fills", "effects"] }, + { id: "i2", name: "Button/secondary", styleOverrideFields: [] }, // (won't be emitted by plugin, but safe) + ] }); + const f = runLint(snap).findings.filter((x) => x.rule_id === "no-instance-restyle-override"); + assert.equal(f.length, 1); + assert.equal(f[0].nodeId, "i1"); + // old plugin (no instances field) -> silent + assert.equal(runLint(base({})).findings.filter((x) => x.rule_id === "no-instance-restyle-override").length, 0); +}); + +test("component-set-has-code-mapping: opt-in; conditional on adoption", () => { + const sets = (mapped) => base({ components: [ + { id: "s1", name: "Button", type: "COMPONENT_SET", hasCodeMapping: mapped[0] }, + { id: "s2", name: "Chip", type: "COMPONENT_SET", hasCodeMapping: mapped[1] }, + ] }); + const run = (m) => runLint(sets(m), { only: ["component-set-has-code-mapping"] }); + // opt-in off by default -> silent even when one linked, one not + assert.equal(runLint(sets([true, false])).findings.filter((f) => f.rule_id === "component-set-has-code-mapping").length, 0); + // enabled: one linked, one not -> flags the unlinked + assert.ok(run([true, false]).findings.some((f) => f.nodeId === "s2")); + // none linked (not adopted) -> silent + assert.equal(run([false, false]).findings.length, 0); + // old plugin (undefined) -> silent + assert.equal(runLint(base({ components: [{ id: "s1", name: "B", type: "COMPONENT_SET" }] }), { only: ["component-set-has-code-mapping"] }).findings.length, 0); +}); + +test("multi-brand-alias-discipline: multi-hop chain follows the SAME mode (not order-dependent)", () => { + const snap = base({ + collections: [ + { id: "P", name: "Primitives", defaultModeId: "p", modes: [{ modeId: "p", name: "V" }] }, + { id: "S", name: "Semantic", defaultModeId: "L", modes: [{ modeId: "L", name: "Light" }, { modeId: "D", name: "Dark" }] }, + ], + variables: [ + { ...V("p_blue", "blue/500", "P", { p: { r: 0, g: 0, b: 1 } }), hiddenFromPublishing: true }, + V("brand_primary", "brand/primary", "S", { L: A("p_blue"), D: A("p_blue") }), + // per-mode divergent: LIGHT -> primitive, DARK -> brand layer + V("mid", "mid/tone", "S", { L: A("p_blue"), D: A("brand_primary") }), + V("accent", "accent/default", "S", { L: A("mid"), D: A("mid") }), + ], + }); + const rep = runLint(snap, { enable: ["multi-brand-alias-discipline"], config: { "multi-brand-alias-discipline": { brandPrefix: "brand" } } }); + // Mode-threaded: accent's DARK chain (accent->mid[D]->brand_primary) reaches brand => routed => NOT flagged. + // The old first-mode-collapse bug would follow mid's LIGHT value (p_blue) in both modes => fabricate a finding. + assert.ok(!rep.findings.some((f) => f.rule_id === "multi-brand-alias-discipline" && f.variableId === "accent")); +}); + +test("detached-component-frame-signal: opt-in; exact name+structure match fires, complete fingerprints only", () => { + const snap = base({ + components: [{ id: "c1", name: "Card", type: "COMPONENT", childTypeSeq: ["FRAME", "TEXT"], childCount: 2 }], + frameDupCandidates: [ + { id: "f1", name: "Card", childTypeSeq: ["FRAME", "TEXT"], childCount: 2 }, // exact match -> fires + { id: "f2", name: "Card", childTypeSeq: ["FRAME"], childCount: 1 }, // structure differs -> no + { id: "f3", name: "Other", childTypeSeq: ["FRAME", "TEXT"], childCount: 2 }, // name differs -> no + ], + }); + // opt-in off -> silent + assert.equal(runLint(snap).findings.filter((f) => f.rule_id === "detached-component-frame-signal").length, 0); + // enabled -> only f1 + const f = runLint(snap, { enable: ["detached-component-frame-signal"] }).findings.filter((x) => x.rule_id === "detached-component-frame-signal"); + assert.equal(f.length, 1); + assert.equal(f[0].nodeId, "f1"); + // truncated fingerprint (childCount > seq length) -> skipped even if prefix matches + const trunc = base({ + components: [{ id: "c1", name: "Card", type: "COMPONENT", childTypeSeq: ["FRAME", "TEXT"], childCount: 5 }], + frameDupCandidates: [{ id: "f1", name: "Card", childTypeSeq: ["FRAME", "TEXT"], childCount: 5 }], + }); + assert.equal(runLint(trunc, { enable: ["detached-component-frame-signal"] }).findings.filter((f) => f.rule_id === "detached-component-frame-signal").length, 0); +});