Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions figma-limitless-mcp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,8 +59,10 @@ structure right?" — no network, no Figma AI credits.
`(snap, config?) => PartialFinding[]` in `server/src/lint/detectors/<tier>.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 →
Expand Down
116 changes: 116 additions & 0 deletions figma-limitless-mcp/plugin/src/main/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean | undefined>(mappingEntries);

const snapComponents = componentNodes.map((n) => {
// A variant COMPONENT (child of a COMPONENT_SET) throws on
// componentPropertyDefinitions — only sets and standalone components
Expand All @@ -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<Record<string, string>> = [];
Expand Down Expand Up @@ -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<string>();
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<string>();
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,
Expand All @@ -4755,6 +4866,11 @@ const handleRequest = async (
nodeBindings,
bindingsTruncated,
componentScanTruncated,
instances,
instanceScanTruncated,
frameDupCandidates,
frameDupScanTruncated,
codeMappingScanTruncated,
meta: {
pageCount: figma.root.children.length,
scannedAllPages: true,
Expand Down
15 changes: 15 additions & 0 deletions figma-limitless-mcp/server/src/lint/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ export const RULE_CONFIG: Record<string, RuleConfigMeta> = {
};
},
},
"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
Expand Down
53 changes: 53 additions & 0 deletions figma-limitless-mcp/server/src/lint/detectors/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof analyze>,
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);
Expand Down Expand Up @@ -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<string, Detector> = {
"fg-bg-pair-contrast": fgBgPairContrast,
"border-icon-graphical-contrast": borderIconGraphicalContrast,
"min-font-size": minFontSize,
"contrast-fallback-export-sampling": contrastFallbackExportSampling,
};
26 changes: 26 additions & 0 deletions figma-limitless-mcp/server/src/lint/detectors/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,37 @@ 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<string, Detector> = {
"published-variable-has-codesyntax-web": publishedVariableHasCodesyntaxWeb,
"codesyntax-web-unique": codesyntaxWebUnique,
"published-variable-has-description": publishedVariableHasDescription,
"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,
};
83 changes: 83 additions & 0 deletions figma-limitless-mcp/server/src/lint/detectors/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Array<{ name: string; seq: string[]; count: number }>>();
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<string, Detector> = {
"property-name-convention-unique": propertyNameConventionUnique,
"boolean-vocab-variant-should-be-boolean": booleanVocabVariantShouldBeBoolean,
Expand All @@ -260,4 +340,7 @@ export const componentDetectors: Record<string, Detector> = {
"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,
};
Loading