From 7c2431025712d77cfcb2e24be512140c4453fc19 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:52:48 +0530 Subject: [PATCH 1/5] =?UTF-8?q?feat(lint):=202=20offline=20deferred=20rule?= =?UTF-8?q?s=20=E2=80=94=20default-variant=20+=20multi-brand=20(51?= =?UTF-8?q?=E2=86=9253/57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both opt-in (defaultOn:false), so the default lint stays precise: - default-variant-is-base-tuple (components): uses the enrichment's defaultVariantTuple already gathered in Wave 10b; flags a COMPONENT_SET whose default variant isn't each axis's first-authored (base) option. "base" is a house convention, hence opt-in. - multi-brand-alias-discipline (theming, config {brandPrefix, roles?}): pure over the alias graph. A brandable semantic (accent/action/… role) should alias THROUGH a brand/* token so re-branding is a single-layer swap; flags one whose chain reaches a primitive without a brand hop. The brand layer itself is exempt. Only meaningful for multi-brand DSs, so opt-in + requires the brand layer declared via config. +3 tests. Only the 4 data-dependent rules remain pending (need plugin gather for instance overrides / dev resources / frame fingerprints / exportAsync sampling). 41 tests pass; golden still 0. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/server/src/lint/config.ts | 15 +++++ .../server/src/lint/detectors/components.ts | 31 +++++++++ .../server/src/lint/detectors/theming.ts | 55 +++++++++++++++- .../server/src/lint/registry.ts | 2 + .../server/test/wave11-rules.test.mjs | 64 +++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 figma-limitless-mcp/server/test/wave11-rules.test.mjs 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/components.ts b/figma-limitless-mcp/server/src/lint/detectors/components.ts index b13fb7a..2dada12 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/components.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/components.ts @@ -250,6 +250,36 @@ 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; +}; + export const componentDetectors: Record = { "property-name-convention-unique": propertyNameConventionUnique, "boolean-vocab-variant-should-be-boolean": booleanVocabVariantShouldBeBoolean, @@ -260,4 +290,5 @@ export const componentDetectors: Record = { "shared-property-value-consistency": sharedPropertyValueConsistency, "no-dead-component-property": noDeadComponentProperty, "variant-matrix-complete": variantMatrixComplete, + "default-variant-is-base-tuple": defaultVariantIsBaseTuple, }; diff --git a/figma-limitless-mcp/server/src/lint/detectors/theming.ts b/figma-limitless-mcp/server/src/lint/detectors/theming.ts index faa9c27..59d0c38 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,58 @@ 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? + let routed = false; + for (const val of Object.values(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; + } + t = aliasTarget(Object.values(tv.valuesByMode)[0]); + 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 +174,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..53b0bae 100644 --- a/figma-limitless-mcp/server/src/lint/registry.ts +++ b/figma-limitless-mcp/server/src/lint/registry.ts @@ -291,6 +291,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.", }, @@ -447,6 +448,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/test/wave11-rules.test.mjs b/figma-limitless-mcp/server/test/wave11-rules.test.mjs new file mode 100644 index 0000000..d90a40e --- /dev/null +++ b/figma-limitless-mcp/server/test/wave11-rules.test.mjs @@ -0,0 +1,64 @@ +// 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")); +}); From 527e5b98075d1a5fa9666c261478d889376f37fc Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:58:07 +0530 Subject: [PATCH 2/5] =?UTF-8?q?feat(lint):=20contrast-fallback-export-samp?= =?UTF-8?q?ling=20offline=20via=20alpha=20(53=E2=86=9254/57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule's real job (info) is to FLAG color tokens whose alias-resolved contrast is untrustworthy — i.e. TRANSLUCENT paints that composite over their backdrop. Alpha is already in the snapshot's colour values, so this needs no exportAsync/pixel-sampling in the gather (which the design panel rightly flagged as the heaviest/riskiest path). Resolves each semantic fg/bg/border token's alias chain to its raw alpha and flags alpha < 1, pointing the user at pixel sampling for those specific tokens. +1 test. Co-Authored-By: Claude Opus 4.8 --- .../server/src/lint/detectors/a11y.ts | 53 +++++++++++++++++++ .../server/test/wave11-rules.test.mjs | 22 ++++++++ 2 files changed, 75 insertions(+) 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/test/wave11-rules.test.mjs b/figma-limitless-mcp/server/test/wave11-rules.test.mjs index d90a40e..72976cc 100644 --- a/figma-limitless-mcp/server/test/wave11-rules.test.mjs +++ b/figma-limitless-mcp/server/test/wave11-rules.test.mjs @@ -62,3 +62,25 @@ test("multi-brand-alias-discipline: off by default; missing config -> config_err 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")); +}); From 3aba6729bbc692a71754c4e0959e839eaa9d6349 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:22:40 +0530 Subject: [PATCH 3/5] =?UTF-8?q?feat(lint):=20final=203=20gather-driven=20r?= =?UTF-8?q?ules=20=E2=80=94=2057/57=20detectors=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the plugin lint_run with three bounded gathers (all degrade to silent on an old plugin; a NEW plugin always emits the keys — even [] — so undefined=not-gathered vs []=scanned-clean): - no-instance-restyle-override (components, warn): scans INSTANCE nodes (INSTANCE_SCAN_BUDGET=20k, MAX_RESTYLED_INSTANCES=300) and emits ONLY TOP-LEVEL instances carrying STYLE overrides (fills/strokes/effects/ *StyleId) — nested instances are skipped so a restyle isn't double-reported on its container. Membership == the finding. - component-set-has-code-mapping (code-output, warn): getDevResourcesAsync per COMPONENT_SET, batched via Promise.all + capped (120). CONDITIONAL on adoption — only flags missing mappings once SOME set has one, so a team not using Code Connect isn't spammed; capped/errored sets are undefined and excluded from numerator+denominator. - detached-component-frame-signal (components, info, OPT-IN): name-prefilters the FRAME population by collision with a standalone-component name, then flags an EXACT name + direct-child-type-sequence match. Both fingerprints must be complete (childCount===seq.length) so a capped >40-child seq never yields a partial-prefix false match. Opt-in given inherent heuristic noise. Every new array is capped + *Truncated-flagged; truncation causes false negatives only, never a fabricated finding. Server types + detectors in lockstep. +3 tests (45 total); golden still 0. Detector coverage now 57/57 — the entire canonical catalog is implemented. Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/plugin/src/main/code.ts | 116 ++++++++++++++++++ .../server/src/lint/detectors/codegen.ts | 24 ++++ .../server/src/lint/detectors/components.ts | 52 ++++++++ .../server/src/lint/registry.ts | 1 + figma-limitless-mcp/server/src/lint/runner.ts | 30 +++++ .../server/test/wave11-rules.test.mjs | 50 ++++++++ 6 files changed, 273 insertions(+) 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/detectors/codegen.ts b/figma-limitless-mcp/server/src/lint/detectors/codegen.ts index fa7053d..0cb5a21 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/codegen.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/codegen.ts @@ -142,6 +142,29 @@ const textLayerUsesStyleOrBoundType: Detector = (snap) => { return out; }; +// Wave 11b (needs the per-set getDevResourcesAsync gather). CONDITIONAL on +// adoption: only flag missing mappings once SOME set has one — a team that +// doesn't use Code Connect isn't spammed. Sets past the async cap carry +// hasCodeMapping===undefined => excluded from both numerator and denominator. +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 []; // not adopted — don't spam + 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 Code Connect / dev-resource mapping, but others do; add one via set_code_mapping so codegen resolves its props.`, + }); + } + } + return out; +}; + export const codegenDetectors: Record = { "published-variable-has-codesyntax-web": publishedVariableHasCodesyntaxWeb, "codesyntax-web-unique": codesyntaxWebUnique, @@ -149,4 +172,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 2dada12..c2e4825 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/components.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/components.ts @@ -280,6 +280,56 @@ const defaultVariantIsBaseTuple: Detector = (snap) => { 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, @@ -291,4 +341,6 @@ export const componentDetectors: Record = { "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/registry.ts b/figma-limitless-mcp/server/src/lint/registry.ts index 53b0bae..75f6345 100644 --- a/figma-limitless-mcp/server/src/lint/registry.ts +++ b/figma-limitless-mcp/server/src/lint/registry.ts @@ -440,6 +440,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.", }, 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 index 72976cc..3d454bf 100644 --- a/figma-limitless-mcp/server/test/wave11-rules.test.mjs +++ b/figma-limitless-mcp/server/test/wave11-rules.test.mjs @@ -84,3 +84,53 @@ test("contrast-fallback-export-sampling: flags translucent semantic colour token 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: 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] }, + ] }); + // one mapped, one not -> flags the unmapped + const rep = runLint(sets([true, false])); + assert.ok(rep.findings.some((f) => f.rule_id === "component-set-has-code-mapping" && f.nodeId === "s2")); + // none mapped (not adopted) -> silent + assert.equal(runLint(sets([false, false])).findings.filter((f) => f.rule_id === "component-set-has-code-mapping").length, 0); + // old plugin (undefined) -> silent + assert.equal(runLint(base({ components: [{ id: "s1", name: "B", type: "COMPONENT_SET" }] })).findings.filter((f) => f.rule_id === "component-set-has-code-mapping").length, 0); +}); + +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); +}); From ef4201a3dd0068c120c03b1f52639b0de928cd99 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:40:30 +0530 Subject: [PATCH 4/5] fix(lint): address 3 confirmed Wave 11 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-lens adversarial review (refute-by-default): 3 confirmed, 3 refuted (instance scan, detached-frame, default-variant, backward-compat, and contrast-fallback all held). 1. component-set-has-code-mapping (medium+low, same root): the plugin sourced hasCodeMapping from getDevResourcesAsync, which returns ANY Dev Mode link (Storybook/Jira/docs) — NOT Code Connect. So one stray link on one set tripped the adoption gate and spammed a false finding on every other set, and the suggested remedy (set_code_mapping) writes a disjoint server-side store so the warning never cleared. Fix: make the rule opt-in (defaultOn:false) and reword it honestly as a dev-resource-presence proxy (not Code Connect); drop the misdirected set_code_mapping remedy. 2. multi-brand-alias-discipline (medium): the multi-hop alias walk advanced via Object.values(target.valuesByMode)[0] — the target's FIRST mode regardless of the mode being followed — making results order-dependent and able to fabricate a finding on per-mode-divergent chains. Fix: thread the current mode through the walk (fall back to first mode only when absent), mirroring resolveColor/resolveAlpha. Added a multi-hop per-mode-divergent regression test that fails under the old collapse. Server-only (no plugin change); neither rule changes a default lint (both opt-in). 46 tests pass; golden still 0. Co-Authored-By: Claude Opus 4.8 --- .../server/src/lint/detectors/codegen.ts | 14 ++++---- .../server/src/lint/detectors/theming.ts | 10 ++++-- .../server/src/lint/registry.ts | 5 +-- .../server/test/wave11-rules.test.mjs | 36 +++++++++++++++---- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/figma-limitless-mcp/server/src/lint/detectors/codegen.ts b/figma-limitless-mcp/server/src/lint/detectors/codegen.ts index 0cb5a21..8e86eab 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/codegen.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/codegen.ts @@ -142,23 +142,25 @@ const textLayerUsesStyleOrBoundType: Detector = (snap) => { return out; }; -// Wave 11b (needs the per-set getDevResourcesAsync gather). CONDITIONAL on -// adoption: only flag missing mappings once SOME set has one — a team that -// doesn't use Code Connect isn't spammed. Sets past the async cap carry -// hasCodeMapping===undefined => excluded from both numerator and denominator. +// 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 []; // not adopted — don't spam + 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 Code Connect / dev-resource mapping, but others do; add one via set_code_mapping so codegen resolves its props.`, + 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.)`, }); } } diff --git a/figma-limitless-mcp/server/src/lint/detectors/theming.ts b/figma-limitless-mcp/server/src/lint/detectors/theming.ts index 59d0c38..8e841a7 100644 --- a/figma-limitless-mcp/server/src/lint/detectors/theming.ts +++ b/figma-limitless-mcp/server/src/lint/detectors/theming.ts @@ -137,9 +137,12 @@ const multiBrandAliasDiscipline: Detector = (snap, config) => { 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? + // 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 val of Object.values(v.valuesByMode)) { + for (const [modeId, val] of Object.entries(v.valuesByMode)) { let t = aliasTarget(val); const seen = new Set(); let hops = 0; @@ -151,7 +154,8 @@ const multiBrandAliasDiscipline: Detector = (snap, config) => { routed = true; break; } - t = aliasTarget(Object.values(tv.valuesByMode)[0]); + const next = modeId in tv.valuesByMode ? tv.valuesByMode[modeId] : Object.values(tv.valuesByMode)[0]; + t = aliasTarget(next); hops++; } if (routed) break; diff --git a/figma-limitless-mcp/server/src/lint/registry.ts b/figma-limitless-mcp/server/src/lint/registry.ts index 75f6345..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", diff --git a/figma-limitless-mcp/server/test/wave11-rules.test.mjs b/figma-limitless-mcp/server/test/wave11-rules.test.mjs index 3d454bf..34efac9 100644 --- a/figma-limitless-mcp/server/test/wave11-rules.test.mjs +++ b/figma-limitless-mcp/server/test/wave11-rules.test.mjs @@ -98,18 +98,40 @@ test("no-instance-restyle-override: flags restyled instances, silent on old plug assert.equal(runLint(base({})).findings.filter((x) => x.rule_id === "no-instance-restyle-override").length, 0); }); -test("component-set-has-code-mapping: conditional on adoption", () => { +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] }, ] }); - // one mapped, one not -> flags the unmapped - const rep = runLint(sets([true, false])); - assert.ok(rep.findings.some((f) => f.rule_id === "component-set-has-code-mapping" && f.nodeId === "s2")); - // none mapped (not adopted) -> silent - assert.equal(runLint(sets([false, false])).findings.filter((f) => f.rule_id === "component-set-has-code-mapping").length, 0); + 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" }] })).findings.filter((f) => f.rule_id === "component-set-has-code-mapping").length, 0); + 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", () => { From 6f0197cda9a391eaaf6267190e501f5e180277f1 Mon Sep 17 00:00:00 2001 From: Tushar Kant Naik <61114548+tknatwork@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:42:10 +0530 Subject: [PATCH 5/5] =?UTF-8?q?docs(lint):=20AGENTS.md=20=E2=80=94=20catal?= =?UTF-8?q?og=20fully=20implemented=20(57/57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- figma-limitless-mcp/AGENTS.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 →