From fd5910866ee9fb561dba5dc4da1b719a196f7bc1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 2 Sep 2026 21:44:18 +0900 Subject: [PATCH 01/69] feat: ground-truth reader and validation tools to stop token/endpoint/column fabrication Add three read-only MCP tools (10 total) so an agent can never invent a project identifier it never verified against a real file: - devup_project_context (scope: theme|api|db|all) reads a project's real devup.json theme tokens, openapi.json endpoints/schemas, or Vespertide models/*.json tables/columns/enums fresh on every call (no session cache). Missing target files return the shared {found:false,guardrail:{action:'stop-and-report',...}} envelope instead of guessing, generalizing the pattern already proven in diagnostics::host_requirement for needs_figma. - devup_ui_validate parses TSX with the existing oxc_parser/oxc_allocator/oxc_span stack (now plus oxc_ast/oxc_ast_visit) and flags unknown \ references with edit-distance-suggested real tokens, hardcoded hex colors/px lengths with a matching token, unknown props on Box/Flex/Text/Center/Grid/Image (checked against devup-ui's published Style Props API reference, not invented), and non-static values inside css()/globalCss()/keyframes() calls specifically - verified against devup-ui's own docs that plain JSX style props (bg={dynamic}) compile to a CSS variable and must not be flagged. - devup_stack_diff detects drift across vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client. Every finding carries an explicit low/medium confidence since these are text/JSON heuristics, not a real compiler front end. Regression-tested against the exact incident that motivated this work: three agents independently inventing a \ color token, a 16px bubble radius, and a 36px avatar size absent from the real devup.json. Read-only throughout; no changes to devup_figma_* behavior or the --allow-write-root policy. --- .../changepack_log_groundtruth_tools.json | 8 + Cargo.lock | 14 + Cargo.toml | 2 + crates/devup-mcp-devup-ui/Cargo.toml | 2 + crates/devup-mcp-devup-ui/src/lib.rs | 2 + crates/devup-mcp-devup-ui/src/style_props.rs | 574 +++++++++ crates/devup-mcp-devup-ui/src/theme/mod.rs | 5 + .../src/theme/project_theme.rs | 415 +++++++ crates/devup-mcp-devup-ui/src/ui_validate.rs | 587 +++++++++ crates/devup-mcp-figma/src/errors.rs | 2 + crates/devup-mcp/src/server/mod.rs | 63 +- .../devup-mcp/src/server/project_context.rs | 641 ++++++++++ crates/devup-mcp/src/server/project_root.rs | 248 ++++ crates/devup-mcp/src/server/stack_diff.rs | 1056 +++++++++++++++++ crates/devup-mcp/src/server/tools.rs | 43 + .../fixtures/ground-truth-project/devup.json | 25 + .../ground-truth-project/models/message.json | 16 + .../ground-truth-project/openapi.json | 28 + .../ground-truth-project/package.json | 4 + crates/devup-mcp/tests/ground_truth_tools.rs | 493 ++++++++ .../tests/stdio_schema_compat_smoke.rs | 4 +- crates/devup-mcp/tests/stdio_tools.rs | 10 + 22 files changed, 4239 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_groundtruth_tools.json create mode 100644 crates/devup-mcp-devup-ui/src/style_props.rs create mode 100644 crates/devup-mcp-devup-ui/src/theme/project_theme.rs create mode 100644 crates/devup-mcp-devup-ui/src/ui_validate.rs create mode 100644 crates/devup-mcp/src/server/project_context.rs create mode 100644 crates/devup-mcp/src/server/project_root.rs create mode 100644 crates/devup-mcp/src/server/stack_diff.rs create mode 100644 crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json create mode 100644 crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json create mode 100644 crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json create mode 100644 crates/devup-mcp/tests/fixtures/ground-truth-project/package.json create mode 100644 crates/devup-mcp/tests/ground_truth_tools.rs diff --git a/.changepacks/changepack_log_groundtruth_tools.json b/.changepacks/changepack_log_groundtruth_tools.json new file mode 100644 index 0000000..e617b52 --- /dev/null +++ b/.changepacks/changepack_log_groundtruth_tools.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor" + }, + "note": "Add three read-only ground-truth tools so an agent can never fabricate a project identifier it never verified: devup_project_context reads a project's real devup.json theme tokens, openapi.json endpoints/schemas, or Vespertide models/*.json tables/columns/enums fresh on every call (no session cache), returning a shared {found:false,guardrail:{action:'stop-and-report',...}} envelope instead of guessing when the target file is missing; devup_ui_validate parses DevupUI TSX with the existing oxc_parser/oxc_allocator/oxc_span stack via a new oxc_ast_visit-based walker and flags unknown $token references (with edit-distance-suggested existing tokens), hardcoded hex colors/px lengths that match an existing token, unknown props on Box/Flex/Text/Center/Grid/Image (checked against the published devup-ui Style Props API reference, not invented), and non-static values inside css()/globalCss()/keyframes() calls specifically -- verified against devup-ui's own docs and css-utils-literal-only ESLint rule that plain JSX style props (bg={dynamic}) are valid devup-ui and must not be flagged; devup_stack_diff detects drift across vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client with every finding carrying an explicit low/medium confidence since none of the checks is a real compiler front end. Regression-tested against the exact incident that motivated this work: three agents independently inventing a $gray100 color token, a 16px bubble radius, and a 36px avatar size that did not exist in the real project devup.json.", + "date": "2026-09-02T00:00:00+09:00" +} diff --git a/Cargo.lock b/Cargo.lock index ab65617..6abc7e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,6 +712,8 @@ dependencies = [ "devup-mcp-figma", "insta", "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", "oxc_parser", "oxc_span", "pretty_assertions", @@ -1923,6 +1925,18 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "oxc_ast_visit" +version = "0.148.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ec60272a8dead7c6fb21dd9709f66e4033194399296603a2c9f85dc63b5540" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + [[package]] name = "oxc_data_structures" version = "0.148.0" diff --git a/Cargo.toml b/Cargo.toml index 12d46c4..82232e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ keyring = "4.2" insta = { version = "1.48.0", features = ["glob", "json"] } image = { version = "=0.25.10", default-features = false, features = ["png"] } oxc_allocator = "=0.148.0" +oxc_ast = "=0.148.0" +oxc_ast_visit = "=0.148.0" oxc_parser = "=0.148.0" oxc_span = "=0.148.0" rand = "0.10" diff --git a/crates/devup-mcp-devup-ui/Cargo.toml b/crates/devup-mcp-devup-ui/Cargo.toml index 49dc245..9f396fe 100644 --- a/crates/devup-mcp-devup-ui/Cargo.toml +++ b/crates/devup-mcp-devup-ui/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] devup-mcp-figma = { path = "../devup-mcp-figma" } oxc_allocator.workspace = true +oxc_ast.workspace = true +oxc_ast_visit.workspace = true oxc_parser.workspace = true oxc_span.workspace = true serde.workspace = true diff --git a/crates/devup-mcp-devup-ui/src/lib.rs b/crates/devup-mcp-devup-ui/src/lib.rs index 2ee3342..3279a42 100644 --- a/crates/devup-mcp-devup-ui/src/lib.rs +++ b/crates/devup-mcp-devup-ui/src/lib.rs @@ -1,4 +1,6 @@ pub mod codegen; pub mod provenance; +pub mod style_props; pub mod theme; +pub mod ui_validate; pub mod validation; diff --git a/crates/devup-mcp-devup-ui/src/style_props.rs b/crates/devup-mcp-devup-ui/src/style_props.rs new file mode 100644 index 0000000..587623b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/style_props.rs @@ -0,0 +1,574 @@ +//! Known `@devup-ui/react` primitive prop names, sourced verbatim from the +//! published Style Props API reference +//! (), +//! not invented. `devup_ui_validate`'s `unknown-prop` rule checks JSX +//! attributes on the primitive elements it recognizes (`Box`, `Flex`, +//! `Text`, `Center`, `Grid`, `Image`) against this list plus a small set of +//! standard React/HTML/devup-ui-specific non-style props; anything else is +//! flagged rather than guessed at. +//! +//! `DEVUP_COLOR_LIKE_PROPS` / `DEVUP_LENGTH_LIKE_PROPS` are the subsets +//! whose CSS-property counterpart is a single color or length value; they +//! drive the `hardcoded-color` / `hardcoded-length` / `unknown-token` +//! rules. Deliberately conservative: composite props like `background` +//! (can hold a gradient) or `border` (shorthand for width+style+color) are +//! excluded from both subsets rather than guessed at, since flagging them +//! incorrectly would repeat exactly the fabrication failure this tool +//! exists to prevent. + +/// devup-ui primitive components whose JSX props this validator checks +/// against [`DEVUP_STYLE_PROPS`] for the `unknown-prop` rule. Custom +/// component names are never flagged: unlike these primitives, a custom +/// component's valid prop set cannot be known statically from devup-ui's +/// public docs, so guessing which props it accepts would risk exactly the +/// kind of invented-fact failure this tool exists to prevent. +pub const DEVUP_PRIMITIVE_ELEMENTS: &[&str] = &["Box", "Flex", "Text", "Center", "Grid", "Image"]; + +/// Non-style props every devup-ui primitive additionally accepts: standard +/// React/HTML attributes, event handlers, and devup-ui-specific structural +/// props (`as`, `selectors`). Checked case-sensitively against the exact +/// attribute name; `data-*`/`aria-*` and pseudo-state (`_hover`, `_dark`, +/// ...) / responsive-condition props are matched by prefix separately in +/// `is_known_non_style_prop`. +const DEVUP_NON_STYLE_PROPS: &[&str] = &[ + "as", + "selectors", + "children", + "className", + "style", + "id", + "key", + "ref", + "role", + "tabIndex", + "title", + "htmlFor", + "for", + "colSpan", + "rowSpan", + "type", + "name", + "value", + "defaultValue", + "placeholder", + "disabled", + "checked", + "defaultChecked", + "readOnly", + "required", + "min", + "max", + "step", + "rows", + "cols", + "src", + "srcSet", + "alt", + "sizes", + "loading", + "decoding", + "href", + "target", + "rel", + "download", + "autoFocus", + "autoComplete", + "form", + "multiple", + "accept", + "list", + "pattern", + "spellCheck", + "draggable", + "contentEditable", + "suppressHydrationWarning", +]; + +/// Returns true for props no primitive-specific check should ever flag: +/// standard React/HTML attributes, `on*` event handlers, `data-*`/`aria-*`, +/// and devup-ui pseudo-state / responsive-condition props (which are +/// user-defined selector keys, not a fixed enumerable set). +pub fn is_known_non_style_prop(name: &str) -> bool { + DEVUP_NON_STYLE_PROPS.contains(&name) + || name.starts_with("on") + || name.starts_with("data-") + || name.starts_with("aria-") + || name.starts_with('_') +} + +pub fn is_known_style_prop(name: &str) -> bool { + DEVUP_STYLE_PROPS.binary_search(&name).is_ok() +} + +pub fn is_color_like_prop(name: &str) -> bool { + DEVUP_COLOR_LIKE_PROPS.binary_search(&name).is_ok() +} + +pub fn is_length_like_prop(name: &str) -> bool { + DEVUP_LENGTH_LIKE_PROPS.binary_search(&name).is_ok() +} + +pub const DEVUP_STYLE_PROPS: &[&str] = &[ + "accentColor", + "alignContent", + "alignItems", + "alignSelf", + "alignmentBaseline", + "animation", + "animationComposition", + "animationDelay", + "animationDir", + "animationDirection", + "animationDuration", + "animationFillMode", + "animationIterationCount", + "animationName", + "animationPlayState", + "animationTimeline", + "animationTimingFunction", + "appearance", + "aspectRatio", + "backdropFilter", + "backfaceVisibility", + "background", + "backgroundAttachment", + "backgroundBlendMode", + "backgroundClip", + "backgroundColor", + "backgroundImage", + "backgroundOrigin", + "backgroundPosition", + "backgroundPositionX", + "backgroundPositionY", + "backgroundRepeat", + "backgroundSize", + "bg", + "bgAttachment", + "bgClip", + "bgColor", + "bgImage", + "bgOrigin", + "bgPosition", + "bgPositionX", + "bgPositionY", + "bgRepeat", + "bgSize", + "border", + "borderBottom", + "borderBottomColor", + "borderBottomLeftRadius", + "borderBottomRightRadius", + "borderBottomStyle", + "borderBottomWidth", + "borderCollapse", + "borderColor", + "borderImage", + "borderImageOutset", + "borderImageRepeat", + "borderImageSlice", + "borderImageSource", + "borderImageWidth", + "borderLeft", + "borderLeftColor", + "borderLeftStyle", + "borderLeftWidth", + "borderRadius", + "borderRight", + "borderRightColor", + "borderRightStyle", + "borderRightWidth", + "borderSpacing", + "borderStyle", + "borderTop", + "borderTopColor", + "borderTopLeftRadius", + "borderTopRightRadius", + "borderTopStyle", + "borderTopWidth", + "borderWidth", + "bottom", + "boxShadow", + "boxSize", + "boxSizing", + "captionSide", + "caret", + "caretColor", + "caretShape", + "clear", + "clipPath", + "clipRule", + "color", + "colorScheme", + "columnGap", + "containIntrinsicBlockSize", + "containIntrinsicHeight", + "containIntrinsicInlineSize", + "containIntrinsicSize", + "containIntrinsicWidth", + "content", + "cursor", + "display", + "dominantBaseline", + "emptyCells", + "fill", + "filter", + "flex", + "flexBasis", + "flexDir", + "flexDirection", + "flexFlow", + "flexGrow", + "flexShrink", + "flexWrap", + "float", + "font", + "fontFamily", + "fontFeatureSettings", + "fontKerning", + "fontLanguageOverride", + "fontOpticalSizing", + "fontSize", + "fontSizeAdjust", + "fontStretch", + "fontStyle", + "fontSynthesis", + "fontVariant", + "fontVariantAlternates", + "fontVariantCaps", + "fontVariantEastAsian", + "fontVariantLigatures", + "fontVariantNumeric", + "fontVariantPosition", + "fontVariationSettings", + "fontWeight", + "forcedColorAdjust", + "gap", + "grid", + "gridArea", + "gridAutoColumns", + "gridAutoFlow", + "gridAutoRows", + "gridColumn", + "gridColumnEnd", + "gridColumnGap", + "gridColumnStart", + "gridGap", + "gridRow", + "gridRowEnd", + "gridRowGap", + "gridRowStart", + "gridTemplate", + "gridTemplateAreas", + "gridTemplateColumns", + "gridTemplateRows", + "h", + "hangingPunctuation", + "height", + "hyphenateLimitChars", + "hyphens", + "imageOrientation", + "imageRendering", + "imageResolution", + "initialLetter", + "inset", + "insetBlock", + "insetBlockEnd", + "insetBlockStart", + "insetInline", + "insetInlineEnd", + "insetInlineStart", + "isolation", + "justifyContent", + "justifyItems", + "justifySelf", + "left", + "letterSpacing", + "lineBreak", + "lineHeight", + "listStyle", + "listStyleImage", + "listStylePosition", + "listStyleType", + "m", + "margin", + "marginBottom", + "marginLeft", + "marginRight", + "marginTop", + "mask", + "maskBorder", + "maskBorderMode", + "maskBorderOutset", + "maskBorderRepeat", + "maskBorderSlice", + "maskBorderSource", + "maskBorderWidth", + "maskClip", + "maskComposite", + "maskImage", + "maskMode", + "maskOrigin", + "maskPosition", + "maskRepeat", + "maskSize", + "maskType", + "maxH", + "maxHeight", + "maxW", + "maxWidth", + "mb", + "minH", + "minHeight", + "minW", + "minWidth", + "mixBlendMode", + "ml", + "mr", + "mt", + "mx", + "my", + "objectFit", + "objectPosition", + "offset", + "offsetAnchor", + "offsetDistance", + "offsetPath", + "offsetPosition", + "offsetRotate", + "opacity", + "order", + "outline", + "outlineColor", + "outlineOffset", + "outlineStyle", + "outlineWidth", + "overflow", + "overflowBlock", + "overflowClipMargin", + "overflowInline", + "overflowWrap", + "overflowX", + "overflowY", + "overscrollBehavior", + "overscrollBehaviorBlock", + "overscrollBehaviorInline", + "overscrollBehaviorX", + "overscrollBehaviorY", + "p", + "padding", + "paddingBottom", + "paddingLeft", + "paddingRight", + "paddingTop", + "pb", + "perspective", + "perspectiveOrigin", + "pl", + "placeContent", + "placeItems", + "placeSelf", + "pointerEvents", + "pos", + "position", + "pr", + "printColorAdjust", + "pt", + "px", + "py", + "resize", + "right", + "rotate", + "rowGap", + "scale", + "scrollBehavior", + "scrollbarColor", + "scrollbarGutter", + "scrollbarWidth", + "shapeImageThreshold", + "shapeMargin", + "shapeOutside", + "stroke", + "strokeOpacity", + "strokeWidth", + "tabSize", + "tableLayout", + "textAlign", + "textAlignLast", + "textDecoration", + "textDecorationColor", + "textDecorationLine", + "textDecorationStyle", + "textEmphasis", + "textEmphasisColor", + "textEmphasisPosition", + "textEmphasisStyle", + "textIndent", + "textJustify", + "textOverflow", + "textRendering", + "textShadow", + "textSizeAdjust", + "textTransform", + "textWrap", + "top", + "transform", + "transformBox", + "transformOrigin", + "transformStyle", + "transition", + "transitionDelay", + "transitionDuration", + "transitionProperty", + "transitionTimingFunction", + "translate", + "typography", + "userSelect", + "verticalAlign", + "viewTransitionName", + "w", + "whiteSpace", + "whiteSpaceCollapse", + "width", + "wordBreak", + "wordSpacing", + "zIndex", +]; + +pub const DEVUP_COLOR_LIKE_PROPS: &[&str] = &[ + "accentColor", + "backgroundColor", + "bgColor", + "borderBottomColor", + "borderColor", + "borderLeftColor", + "borderRightColor", + "borderTopColor", + "caretColor", + "color", + "fill", + "outlineColor", + "scrollbarColor", + "stroke", + "textDecorationColor", + "textEmphasisColor", +]; + +pub const DEVUP_LENGTH_LIKE_PROPS: &[&str] = &[ + "bgSize", + "borderBottomLeftRadius", + "borderBottomRightRadius", + "borderBottomWidth", + "borderLeftWidth", + "borderRadius", + "borderRightWidth", + "borderSpacing", + "borderTopLeftRadius", + "borderTopRightRadius", + "borderTopWidth", + "borderWidth", + "bottom", + "boxSize", + "columnGap", + "containIntrinsicBlockSize", + "containIntrinsicHeight", + "containIntrinsicInlineSize", + "containIntrinsicSize", + "containIntrinsicWidth", + "flexBasis", + "fontSize", + "gap", + "gridColumnGap", + "gridGap", + "gridRowGap", + "h", + "height", + "left", + "letterSpacing", + "lineHeight", + "m", + "margin", + "marginBottom", + "marginLeft", + "marginRight", + "marginTop", + "maxH", + "maxHeight", + "maxW", + "maxWidth", + "mb", + "minH", + "minHeight", + "minW", + "minWidth", + "ml", + "mr", + "mt", + "mx", + "my", + "outlineOffset", + "outlineWidth", + "overflowClipMargin", + "p", + "padding", + "paddingBottom", + "paddingLeft", + "paddingRight", + "paddingTop", + "pb", + "pl", + "pr", + "pt", + "px", + "py", + "right", + "rowGap", + "shapeMargin", + "strokeWidth", + "tabSize", + "top", + "w", + "width", + "wordSpacing", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_prop_lists_are_sorted_for_binary_search() { + let mut sorted = DEVUP_STYLE_PROPS.to_vec(); + sorted.sort_unstable(); + assert_eq!(DEVUP_STYLE_PROPS, sorted.as_slice()); + let mut colors = DEVUP_COLOR_LIKE_PROPS.to_vec(); + colors.sort_unstable(); + assert_eq!(DEVUP_COLOR_LIKE_PROPS, colors.as_slice()); + let mut lengths = DEVUP_LENGTH_LIKE_PROPS.to_vec(); + lengths.sort_unstable(); + assert_eq!(DEVUP_LENGTH_LIKE_PROPS, lengths.as_slice()); + } + + #[test] + fn color_and_length_subsets_are_subsets_of_style_props() { + for prop in DEVUP_COLOR_LIKE_PROPS { + assert!(is_known_style_prop(prop), "{prop} missing from style props"); + } + for prop in DEVUP_LENGTH_LIKE_PROPS { + assert!(is_known_style_prop(prop), "{prop} missing from style props"); + } + } + + #[test] + fn recognizes_known_and_rejects_unknown_props() { + assert!(is_known_style_prop("bg")); + assert!(is_known_style_prop("borderRadius")); + assert!(!is_known_style_prop("bgg")); + assert!(is_color_like_prop("bgColor")); + assert!(!is_color_like_prop("bg")); + assert!(is_length_like_prop("w")); + assert!(is_known_non_style_prop("onClick")); + assert!(is_known_non_style_prop("data-testid")); + assert!(is_known_non_style_prop("_hover")); + assert!(is_known_non_style_prop("as")); + } +} diff --git a/crates/devup-mcp-devup-ui/src/theme/mod.rs b/crates/devup-mcp-devup-ui/src/theme/mod.rs index 17d0642..12271ec 100644 --- a/crates/devup-mcp-devup-ui/src/theme/mod.rs +++ b/crates/devup-mcp-devup-ui/src/theme/mod.rs @@ -1,4 +1,5 @@ mod devup_json; +mod project_theme; mod tokens; pub(crate) use tokens::{normalize_token, variable_token}; @@ -9,3 +10,7 @@ pub use devup_json::{ VariableMode, VariableSnapshot, VariableStyle, generate_devup_json, variable_snapshot_from_result, }; +pub use project_theme::{ + ProjectTheme, TokenCategory, TokenEntry, closest_tokens, edit_distance, normalize_identifier, + parse_project_theme, +}; diff --git a/crates/devup-mcp-devup-ui/src/theme/project_theme.rs b/crates/devup-mcp-devup-ui/src/theme/project_theme.rs new file mode 100644 index 0000000..a821764 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/theme/project_theme.rs @@ -0,0 +1,415 @@ +//! Reads an on-disk project `devup.json` — the file an application actually +//! ships, authored by hand or generated once by `devup_figma_to_json` — and +//! exposes the token names and resolved values it actually defines. +//! +//! This is deliberately a *different* type from [`super::VariableSnapshot`]: +//! `VariableSnapshot` is the raw Figma variable/style export this crate +//! projects *into* a `devup.json` string. [`ProjectTheme`] instead *reads +//! back* an already-materialized `devup.json` file so a caller (the +//! `devup_project_context` and `devup_ui_validate` MCP tools) can check +//! whether a `$token` an agent wants to use actually exists in the project, +//! instead of guessing. See `README.md`'s brief for the incident this +//! guards against: three agents independently invented `$gray100`, a +//! 16px bubble radius, and a 36px avatar size that did not exist in the +//! project's real `devup.json`. +//! +//! `devup.json`'s `theme.colors` / `theme.length` / `theme.shadow` are +//! conventionally mode-keyed (`{"default": {"primary": "#000"}, "dark": {...}}`, +//! matching [`super::generate_devup_json`]'s own output), but hand-authored +//! files sometimes flatten a single-mode theme directly to +//! `{"primary": "#000"}`. [`parse_project_theme`] accepts both shapes: +//! second-level values that are themselves JSON objects are treated as a +//! mode name containing tokens; scalar/array second-level values are +//! treated as tokens of an implicit `"default"` mode. + +use std::collections::BTreeMap; + +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde_json::Value; + +use super::tokens::normalize_token; + +/// Which theme axis a token belongs to. Mirrors `devup.json`'s +/// `theme.{colors,typography,length,shadow}` keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TokenCategory { + Colors, + Typography, + Length, + Shadow, +} + +impl TokenCategory { + pub fn as_str(self) -> &'static str { + match self { + TokenCategory::Colors => "colors", + TokenCategory::Typography => "typography", + TokenCategory::Length => "length", + TokenCategory::Shadow => "shadow", + } + } +} + +/// A theme token's resolved value(s) across whichever modes define it. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenEntry { + pub category: TokenCategory, + /// mode name -> resolved value. Typography tokens (which `devup.json` + /// never mode-keys) use the single implicit mode `"default"`. + pub values_by_mode: BTreeMap, +} + +/// A project's `devup.json`, as actually read from disk: only the tokens it +/// defines, nothing inferred or assumed. +#[derive(Debug, Clone, Default)] +pub struct ProjectTheme { + /// mode -> token -> value + pub colors: BTreeMap>, + /// token -> value (devup.json never mode-keys typography) + pub typography: BTreeMap, + /// mode -> token -> value + pub length: BTreeMap>, + /// mode -> token -> value + pub shadow: BTreeMap>, +} + +impl ProjectTheme { + /// All mode names any category actually defines, sorted and deduplicated. + pub fn modes(&self) -> Vec { + let mut modes = self + .colors + .keys() + .chain(self.length.keys()) + .chain(self.shadow.keys()) + .cloned() + .collect::>(); + modes.sort(); + modes.dedup(); + modes + } + + /// A flat catalog of every token this theme defines, keyed by token + /// name, merged across categories. `devup_ui_validate` uses this to + /// check whether a referenced `$token` exists anywhere in the theme; + /// `devup_project_context` uses the per-category maps directly so it + /// can report which axis (`colors`/`typography`/`length`/`shadow`) a + /// token belongs to. + pub fn token_catalog(&self) -> BTreeMap { + let mut catalog = BTreeMap::new(); + for (mode, tokens) in &self.colors { + for (token, value) in tokens { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Colors, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert(mode.clone(), value.clone()); + } + } + for (token, value) in &self.typography { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Typography, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert("default".to_owned(), value.clone()); + } + for (mode, tokens) in &self.length { + for (token, value) in tokens { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Length, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert(mode.clone(), value.clone()); + } + } + for (mode, tokens) in &self.shadow { + for (token, value) in tokens { + catalog + .entry(token.clone()) + .or_insert_with(|| TokenEntry { + category: TokenCategory::Shadow, + values_by_mode: BTreeMap::new(), + }) + .values_by_mode + .insert(mode.clone(), value.clone()); + } + } + catalog + } + + pub fn contains_token(&self, token: &str) -> bool { + self.colors + .values() + .any(|tokens| tokens.contains_key(token)) + || self.typography.contains_key(token) + || self + .length + .values() + .any(|tokens| tokens.contains_key(token)) + || self + .shadow + .values() + .any(|tokens| tokens.contains_key(token)) + } + + pub fn token_count(&self) -> usize { + self.token_catalog().len() + } + + /// Color tokens (any mode) whose resolved value normalizes to the same + /// hex string as `hex`. Used to suggest an existing token instead of a + /// hardcoded color. + pub fn color_tokens_matching_hex(&self, hex: &str) -> Vec { + let normalized = normalize_hex(hex); + let mut matches = self + .colors + .values() + .flat_map(|tokens| tokens.iter()) + .filter(|(_, value)| { + value + .as_str() + .is_some_and(|candidate| normalize_hex(candidate) == normalized) + }) + .map(|(token, _)| token.clone()) + .collect::>(); + matches.sort(); + matches.dedup(); + matches + } + + /// Length tokens (any mode) whose resolved value equals `px` (e.g. + /// `"16px"`) exactly as written. + pub fn length_tokens_matching_px(&self, px: &str) -> Vec { + let mut matches = self + .length + .values() + .flat_map(|tokens| tokens.iter()) + .filter(|(_, value)| value.as_str() == Some(px)) + .map(|(token, _)| token.clone()) + .collect::>(); + matches.sort(); + matches.dedup(); + matches + } +} + +fn normalize_hex(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +/// Parses a project's `devup.json` file content (the whole file, i.e. the +/// object with the top-level `theme` key) into a [`ProjectTheme`]. +/// +/// Never invents or assumes structure: a missing `theme` key, or a missing +/// category under it, simply yields an empty map for that category rather +/// than an error. Malformed JSON is the only parse failure. +pub fn parse_project_theme(source: &str) -> Result { + let root: Value = serde_json::from_str(source).map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "devup.json을 JSON으로 파싱하지 못했습니다.", + false, + serde_json::json!({ "parseError": error.to_string() }), + ) + })?; + let theme = root.get("theme").cloned().unwrap_or(Value::Null); + Ok(ProjectTheme { + colors: parse_mode_keyed(theme.get("colors")), + typography: parse_flat(theme.get("typography")), + length: parse_mode_keyed(theme.get("length")), + shadow: parse_mode_keyed(theme.get("shadow")), + }) +} + +/// Parses a `theme.` value that is conventionally mode-keyed +/// (`{"default": {"token": value}}`) but tolerates a flattened single-mode +/// shape (`{"token": value}`) by treating it as the `"default"` mode. +/// Distinguishes the two shapes per top-level entry: an entry whose value is +/// itself a JSON object is treated as `mode -> tokens`; an entry whose value +/// is a scalar/array is treated as a token of the implicit `"default"` mode. +fn parse_mode_keyed(value: Option<&Value>) -> BTreeMap> { + let mut result = BTreeMap::>::new(); + let Some(Value::Object(entries)) = value else { + return result; + }; + for (key, entry) in entries { + match entry { + Value::Object(tokens) => { + let mode_tokens = result.entry(key.clone()).or_default(); + for (token, token_value) in tokens { + mode_tokens.insert(token.clone(), token_value.clone()); + } + } + other => { + result + .entry("default".to_owned()) + .or_default() + .insert(key.clone(), other.clone()); + } + } + } + result +} + +fn parse_flat(value: Option<&Value>) -> BTreeMap { + let Some(Value::Object(entries)) = value else { + return BTreeMap::new(); + }; + entries + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +/// Simple Levenshtein edit distance, used only to suggest the closest +/// existing token names for a `$token` that does not exist. Deliberately +/// unweighted (all edits cost 1): this is a "did you mean" hint, not a +/// scored ranking algorithm. +pub fn edit_distance(left: &str, right: &str) -> usize { + let left = left.chars().collect::>(); + let right = right.chars().collect::>(); + let mut previous_row = (0..=right.len()).collect::>(); + let mut current_row = vec![0usize; right.len() + 1]; + for (i, &left_char) in left.iter().enumerate() { + current_row[0] = i + 1; + for (j, &right_char) in right.iter().enumerate() { + let cost = usize::from(left_char != right_char); + current_row[j + 1] = (current_row[j] + 1) + .min(previous_row[j + 1] + 1) + .min(previous_row[j] + cost); + } + std::mem::swap(&mut previous_row, &mut current_row); + } + previous_row[right.len()] +} + +/// Returns up to `limit` token names from `catalog` closest to `query` by +/// edit distance, sorted by distance then name. Empty if `catalog` is empty. +pub fn closest_tokens<'a>( + query: &str, + catalog: impl Iterator, + limit: usize, +) -> Vec { + let mut scored = catalog + .map(|token| (edit_distance(query, token), token.clone())) + .collect::>(); + scored.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + scored + .into_iter() + .take(limit) + .map(|(_, token)| token) + .collect() +} + +/// Confirms [`normalize_token`] stays reachable for callers that need +/// devup.json-style token normalization alongside project-theme reading +/// (`devup_project_context`'s `api`/`db` scopes derive suggested +/// identifiers the same way theme tokens are named). +pub fn normalize_identifier(input: &str) -> String { + normalize_token(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_mode_keyed_colors_and_flat_typography() { + let source = r##"{ + "theme": { + "colors": { + "default": { "primary": "#111111", "background": "#ffffff" }, + "dark": { "primary": "#eeeeee", "background": "#000000" } + }, + "typography": { + "body1": { "fontSize": "14px", "lineHeight": "20px" } + }, + "length": { + "default": { "sm": "8px", "md": "16px" } + }, + "shadow": { + "default": { "card": "0 1px 2px rgba(0,0,0,0.1)" } + } + } + }"##; + let theme = parse_project_theme(source).expect("valid devup.json"); + assert_eq!( + theme.colors["default"]["primary"], + Value::String("#111111".to_owned()) + ); + assert_eq!( + theme.colors["dark"]["primary"], + Value::String("#eeeeee".to_owned()) + ); + assert!(theme.typography.contains_key("body1")); + assert_eq!( + theme.length["default"]["md"], + Value::String("16px".to_owned()) + ); + assert!(theme.contains_token("primary")); + assert!(theme.contains_token("md")); + assert!(!theme.contains_token("gray100")); + } + + #[test] + fn tolerates_flattened_single_mode_colors() { + let source = r##"{ "theme": { "colors": { "primary": "#111111" } } }"##; + let theme = parse_project_theme(source).expect("valid devup.json"); + assert_eq!( + theme.colors["default"]["primary"], + Value::String("#111111".to_owned()) + ); + } + + #[test] + fn missing_theme_key_yields_empty_categories_not_an_error() { + let theme = parse_project_theme("{}").expect("empty object is valid JSON"); + assert!(theme.colors.is_empty()); + assert!(theme.typography.is_empty()); + assert_eq!(theme.token_count(), 0); + } + + #[test] + fn rejects_malformed_json() { + let error = parse_project_theme("{ not json").unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + } + + #[test] + fn suggests_closest_tokens_by_edit_distance() { + let source = r##"{ "theme": { "colors": { "default": { + "captionLight": "#999999", "backgroundLight": "#fafafa", "primary": "#111111" + } } } }"##; + let theme = parse_project_theme(source).unwrap(); + let catalog = theme.token_catalog(); + let names = catalog.keys().collect::>(); + let suggestions = closest_tokens("gray100", names.into_iter(), 2); + assert_eq!(suggestions.len(), 2); + } + + #[test] + fn finds_color_tokens_matching_hardcoded_hex() { + let source = r##"{ "theme": { "colors": { "default": { "primary": "#FF0000" } } } }"##; + let theme = parse_project_theme(source).unwrap(); + assert_eq!(theme.color_tokens_matching_hex("#ff0000"), vec!["primary"]); + assert!(theme.color_tokens_matching_hex("#00ff00").is_empty()); + } + + #[test] + fn finds_length_tokens_matching_hardcoded_px() { + let source = r##"{ "theme": { "length": { "default": { "md": "16px" } } } }"##; + let theme = parse_project_theme(source).unwrap(); + assert_eq!(theme.length_tokens_matching_px("16px"), vec!["md"]); + assert!(theme.length_tokens_matching_px("17px").is_empty()); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs new file mode 100644 index 0000000..ce57e68 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -0,0 +1,587 @@ +//! `devup_ui_validate` — the highest-leverage of the three ground-truth +//! tools. Parses TSX with the same `oxc_parser`/`oxc_allocator`/`oxc_span` +//! stack already used to validate every generated TSX (`validation.rs`), +//! then walks the AST with `oxc_ast_visit::Visit` to catch the exact +//! failure class documented in this repository's brief: three agents +//! independently inventing `$gray100` (a color token that does not exist +//! in the project's real `devup.json`), a 16px bubble radius, and a 36px +//! avatar size, none traceable to any source of truth. +//! +//! Two facts verified against `@devup-ui/react`'s own docs and ESLint rule +//! (`css-utils-literal-only`) shape the rules here and deliberately +//! *narrow* what the brief's "런타임 값" wording might suggest: +//! +//! - JSX style props on `Box`/`Flex`/`Text`/... (`bg={dynamicValue}`) ARE +//! valid devup-ui: the compiler lowers them to a CSS custom property at +//! build time (`className="a" style={{"--a": dynamicValue}}`). Flagging +//! these as errors would itself be a fabricated rule. +//! - `css()`, `globalCss()`, and `keyframes()` utility calls are the actual +//! "must be statically analyzable" boundary — devup-ui's own +//! `css-utils-literal-only` ESLint rule rejects variables/expressions +//! there, because these calls are extracted at build time with no +//! runtime fallback. `runtime-value` therefore targets these three call +//! sites, not general JSX props. +//! +//! `unknown-token` / `hardcoded-color` / `hardcoded-length` operate on the +//! `Box`/`Flex`/`Text`/`Center`/`Grid`/`Image` primitives' known color- and +//! length-like props (`style_props.rs`, itself sourced from devup-ui's +//! published Style Props API reference, not invented). + +use std::collections::BTreeSet; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, JSXAttribute, JSXAttributeName, JSXAttributeValue, + JSXElementName, JSXOpeningElement, ObjectExpression, ObjectPropertyKind, PropertyKey, + UnaryOperator, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType, Span}; +use serde::Serialize; + +use crate::style_props::{ + DEVUP_PRIMITIVE_ELEMENTS, is_color_like_prop, is_known_non_style_prop, is_known_style_prop, + is_length_like_prop, +}; +use crate::theme::{ProjectTheme, closest_tokens}; + +/// Devup-ui `css`/`globalCss`/`keyframes` utility call names whose object +/// argument must be statically analyzable (devup-ui's own +/// `css-utils-literal-only` ESLint rule constraint). +const LITERAL_ONLY_CALLS: &[&str] = &["css", "globalCss", "keyframes"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Warning, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Violation { + pub rule: &'static str, + pub severity: Severity, + pub byte_range: [usize; 2], + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggestion: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UiValidation { + pub ok: bool, + pub violations: Vec, + pub checked_tokens: usize, + pub available_token_count: usize, +} + +/// Validates `tsx` against `theme` (a project's real `devup.json`, or +/// `None` if unavailable — in which case `unknown-token` is skipped rather +/// than guessed at; callers should surface `theme` unavailability to the +/// user separately, since silently skipping token checks is different from +/// confirming a token exists). `strict` additionally fails `ok` on +/// `warning`-severity violations. +pub fn validate_devup_ui_tsx( + tsx: &str, + theme: Option<&ProjectTheme>, + strict: bool, +) -> UiValidation { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, tsx, SourceType::tsx()).parse(); + + let mut violations = Vec::new(); + for diagnostic in &parsed.diagnostics { + let (start, end) = diagnostic + .labels + .first() + .map(|label| { + let start = (label.offset() as usize).min(tsx.len()); + let end = start.saturating_add(label.len() as usize).min(tsx.len()); + (start, end) + }) + .unwrap_or((0, 0)); + violations.push(Violation { + rule: "invalid-syntax", + severity: Severity::Error, + byte_range: [start, end], + message: format!("TSX가 TypeScript+JSX 문법 검증을 통과하지 못했습니다: {diagnostic}"), + suggestion: None, + }); + } + + let available_token_count = theme.map(ProjectTheme::token_count).unwrap_or(0); + let mut visitor = TsxVisitor { + theme, + checked_tokens: 0, + violations: Vec::new(), + element_stack: Vec::new(), + }; + visitor.visit_program(&parsed.program); + violations.extend(visitor.violations); + let checked_tokens = visitor.checked_tokens; + + let ok = violations + .iter() + .all(|violation| violation.severity != Severity::Error) + && (!strict || violations.is_empty()); + + UiValidation { + ok, + violations, + checked_tokens, + available_token_count, + } +} + +struct TsxVisitor<'t> { + theme: Option<&'t ProjectTheme>, + checked_tokens: usize, + violations: Vec, + element_stack: Vec>, +} + +impl<'t> TsxVisitor<'t> { + fn current_is_primitive(&self) -> bool { + self.element_stack + .last() + .and_then(|name| name.as_deref()) + .is_some_and(|name| DEVUP_PRIMITIVE_ELEMENTS.contains(&name)) + } + + fn check_attribute_value(&mut self, prop_name: &str, text: &str, span: Span) { + if let Some(token) = text.strip_prefix('$') { + self.checked_tokens += 1; + if let Some(theme) = self.theme + && !theme.contains_token(token) + { + let catalog = theme.token_catalog(); + let names = catalog.keys().collect::>(); + let suggestions = closest_tokens(token, names.into_iter(), 3); + self.violations.push(Violation { + rule: "unknown-token", + severity: Severity::Error, + byte_range: [span.start as usize, span.end as usize], + message: format!("${token}은(는) devup.json에 정의되어 있지 않습니다."), + suggestion: if suggestions.is_empty() { + None + } else { + Some(format!( + "closest existing tokens: {}", + suggestions + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", ") + )) + }, + }); + } + return; + } + if is_color_like_prop(prop_name) && is_hex_color(text) { + let suggestion = self + .theme + .map(|theme| theme.color_tokens_matching_hex(text)); + self.violations.push(Violation { + rule: "hardcoded-color", + severity: Severity::Warning, + byte_range: [span.start as usize, span.end as usize], + message: format!( + "{prop_name}에 하드코딩된 색상 {text}을(를) 사용했습니다. devup.json 토큰 사용을 고려하세요." + ), + suggestion: match suggestion { + Some(tokens) if !tokens.is_empty() => Some(format!( + "matching tokens: {}", + tokens + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", ") + )), + _ => None, + }, + }); + return; + } + if is_length_like_prop(prop_name) && is_px_length(text) { + let suggestion = self + .theme + .map(|theme| theme.length_tokens_matching_px(text)); + self.violations.push(Violation { + rule: "hardcoded-length", + severity: Severity::Warning, + byte_range: [span.start as usize, span.end as usize], + message: format!( + "{prop_name}에 하드코딩된 길이 {text}을(를) 사용했습니다. devup.json 토큰 사용을 고려하세요." + ), + suggestion: match suggestion { + Some(tokens) if !tokens.is_empty() => Some(format!( + "matching tokens: {}", + tokens + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", ") + )), + _ => None, + }, + }); + } + } + + fn check_unknown_prop(&mut self, prop_name: &str, span: Span) { + if !self.current_is_primitive() { + return; + } + if is_known_style_prop(prop_name) || is_known_non_style_prop(prop_name) { + return; + } + self.violations.push(Violation { + rule: "unknown-prop", + severity: Severity::Error, + byte_range: [span.start as usize, span.end as usize], + message: format!( + "{prop_name}은(는) {}이(가) 인식하는 prop이 아닙니다.", + self.element_stack + .last() + .and_then(|name| name.as_deref()) + .unwrap_or("devup-ui primitive") + ), + suggestion: None, + }); + } + + fn check_literal_only_call(&mut self, call: &CallExpression) { + let Some(callee) = call.callee.get_identifier_reference() else { + return; + }; + if !LITERAL_ONLY_CALLS.contains(&callee.name.as_str()) { + return; + } + let Some(Argument::ObjectExpression(object)) = call.arguments.first() else { + return; + }; + self.check_static_object(object, callee.name.as_str()); + } + + fn check_static_object(&mut self, object: &ObjectExpression, call_name: &str) { + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + let key = property_key_name(&property.key).unwrap_or_else(|| "?".to_owned()); + if !is_static_expression(&property.value) { + self.violations.push(Violation { + rule: "runtime-value", + severity: Severity::Error, + byte_range: [ + property.value.span().start as usize, + property.value.span().end as usize, + ], + message: format!( + "{call_name}({{ {key}: ... }})는 정적으로 분석 가능한 리터럴 값만 허용합니다. 변수나 표현식은 zero-runtime 추출을 깨뜨립니다." + ), + suggestion: None, + }); + } + } + } +} + +impl<'a, 't> Visit<'a> for TsxVisitor<'t> { + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + let tag_name = jsx_element_name(&element.name); + self.element_stack.push(tag_name); + walk::walk_jsx_opening_element(self, element); + self.element_stack.pop(); + } + + fn visit_jsx_attribute(&mut self, attribute: &JSXAttribute<'a>) { + if let JSXAttributeName::Identifier(name) = &attribute.name { + let prop_name = name.name.as_str(); + self.check_unknown_prop(prop_name, name.span); + if let Some(JSXAttributeValue::StringLiteral(literal)) = &attribute.value { + self.check_attribute_value(prop_name, literal.value.as_str(), literal.span); + } + } + walk::walk_jsx_attribute(self, attribute); + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + self.check_literal_only_call(call); + walk::walk_call_expression(self, call); + } +} + +fn jsx_element_name(name: &JSXElementName) -> Option { + match name { + JSXElementName::Identifier(identifier) => Some(identifier.name.as_str().to_owned()), + JSXElementName::IdentifierReference(reference) => Some(reference.name.as_str().to_owned()), + _ => None, + } +} + +fn property_key_name(key: &PropertyKey) -> Option { + match key { + PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.as_str().to_owned()), + PropertyKey::StringLiteral(literal) => Some(literal.value.as_str().to_owned()), + _ => None, + } +} + +/// Static-analysis literal check mirroring devup-ui's `css-utils-literal-only` +/// ESLint rule: string/number/boolean/null literals, unary-negated numeric +/// literals, and arrays/objects composed entirely of such, are allowed. +/// Identifiers, member/call expressions, template literals with +/// substitutions, and any other runtime-dependent expression are not. +fn is_static_expression(expression: &Expression) -> bool { + match expression { + Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NullLiteral(_) => true, + Expression::TemplateLiteral(template) => template.expressions.is_empty(), + Expression::UnaryExpression(unary) => { + matches!( + unary.operator, + UnaryOperator::UnaryNegation | UnaryOperator::UnaryPlus + ) && is_static_expression(&unary.argument) + } + Expression::ArrayExpression(array) => array.elements.iter().all(|element| { + element.as_expression().is_some_and(is_static_expression) || element.is_elision() + }), + Expression::ObjectExpression(object) => { + object.properties.iter().all(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + is_static_expression(&property.value) + } + ObjectPropertyKind::SpreadProperty(_) => false, + }) + } + _ => false, + } +} + +fn is_hex_color(text: &str) -> bool { + let Some(hex) = text.strip_prefix('#') else { + return false; + }; + matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|character| character.is_ascii_hexdigit()) +} + +fn is_px_length(text: &str) -> bool { + let Some(number) = text.strip_suffix("px") else { + return false; + }; + let number = number.strip_prefix('-').unwrap_or(number); + !number.is_empty() + && number + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + && number.matches('.').count() <= 1 +} + +/// All prop-name-independent identifiers this validator can flag, exposed +/// for tests that want to assert coverage without duplicating the rule +/// list. +pub fn rule_names() -> BTreeSet<&'static str> { + [ + "invalid-syntax", + "unknown-token", + "hardcoded-color", + "hardcoded-length", + "unknown-prop", + "runtime-value", + ] + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::parse_project_theme; + + fn fixture_theme() -> ProjectTheme { + parse_project_theme( + r##"{ "theme": { + "colors": { "default": { "captionLight": "#999999", "backgroundLight": "#fafafa" } }, + "typography": {}, + "length": { "default": { "sm": "8px", "md": "16px" } }, + "shadow": {} + } }"##, + ) + .unwrap() + } + + #[test] + fn catches_the_gray100_regression_case() { + let tsx = r##"export const Bubble = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(!report.ok); + assert!( + report + .violations + .iter() + .any(|violation| violation.rule == "unknown-token" + && violation.message.contains("gray100")) + ); + } + + #[test] + fn allows_existing_tokens() { + let tsx = r##"export const Bubble = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(report.ok, "{:?}", report.violations); + assert_eq!(report.checked_tokens, 1); + } + + #[test] + fn flags_hardcoded_hex_color_with_suggestion() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "hardcoded-color") + .expect("hardcoded-color violation"); + assert_eq!(violation.severity, Severity::Warning); + assert!( + violation + .suggestion + .as_deref() + .unwrap() + .contains("captionLight") + ); + } + + #[test] + fn flags_hardcoded_px_length_with_suggestion() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "hardcoded-length") + .expect("hardcoded-length violation"); + assert!(violation.suggestion.as_deref().unwrap().contains("md")); + } + + #[test] + fn dynamic_jsx_props_are_not_flagged_as_runtime_value() { + let tsx = r##"export const X = ({color}) => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "runtime-value"), + "{:?}", + report.violations + ); + } + + #[test] + fn catches_runtime_value_inside_css_call() { + let tsx = r##" + import { css } from '@devup-ui/react' + const v = getValue() + const cls = css({ width: v }) + "##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(!report.ok); + assert!( + report + .violations + .iter() + .any(|violation| violation.rule == "runtime-value") + ); + } + + #[test] + fn allows_literal_only_css_call() { + let tsx = r##" + import { css } from '@devup-ui/react' + const cls = css({ width: 1, height: '100%', items: [1, '2'] }) + "##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(report.ok, "{:?}", report.violations); + } + + #[test] + fn flags_unknown_prop_on_primitive_element() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert!( + report + .violations + .iter() + .any(|violation| violation.rule == "unknown-prop") + ); + } + + #[test] + fn does_not_flag_unknown_prop_on_custom_component() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-prop"), + "{:?}", + report.violations + ); + } + + #[test] + fn does_not_flag_pseudo_and_event_props() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-prop"), + "{:?}", + report.violations + ); + } + + #[test] + fn reports_invalid_syntax_as_violation_not_panic() { + let report = validate_devup_ui_tsx("export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, None, false); + assert_eq!(report.checked_tokens, 1); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-token") + ); + } + + #[test] + fn strict_mode_fails_on_warnings() { + let tsx = r##"export const X = () => ;"##; + let lenient = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let strict = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), true); + assert!(lenient.ok); + assert!(!strict.ok); + } +} diff --git a/crates/devup-mcp-figma/src/errors.rs b/crates/devup-mcp-figma/src/errors.rs index a681a60..e9618b8 100644 --- a/crates/devup-mcp-figma/src/errors.rs +++ b/crates/devup-mcp-figma/src/errors.rs @@ -21,6 +21,8 @@ pub enum ErrorCode { DevupCodegenFailed, DevupThemeConflict, DevupCompatCorpusDrift, + DevupInvalidInput, + DevupProjectRootNotFound, } #[derive(Clone, Serialize, Deserialize)] diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index cb498a0..ff8438f 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -3,9 +3,12 @@ pub mod delivery; mod diagnostics; pub mod handoff; pub mod output; +mod project_context; +mod project_root; mod projection; mod quality; pub mod resources; +mod stack_diff; mod tools; mod validation; @@ -46,7 +49,8 @@ use validation::{ pub use tools::{ AuthInput, ContinueInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, - FigmaSearchInput, FigmaToJsonInput, FigmaToUiInput, + FigmaSearchInput, FigmaToJsonInput, FigmaToUiInput, ProjectContextInput, StackDiffInput, + UiValidateInput, }; const FIGMA_ENDPOINT: &str = "https://mcp.figma.com/mcp"; @@ -731,6 +735,63 @@ impl DevupServer { .map_err(to_mcp_error)?; Ok(tool_result(result)) } + + #[tool( + description = "Read a project's real devup.json theme tokens, openapi.json endpoints/schemas, or Vespertide models/*.json tables/columns (scope: theme | api | db | all) — read-only, no session cache, never guesses", + output_schema = permissive_object_output_schema() + )] + async fn devup_project_context( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = project_context::run( + &input.scope, + input.project_root.as_deref(), + input.filter.as_deref(), + ) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } + + #[tool( + description = "Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths with a matching token, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls", + output_schema = permissive_object_output_schema() + )] + async fn devup_ui_validate( + &self, + Parameters(input): Parameters, + ) -> Result { + let theme_lookup = project_context::theme_for_validation(input.project_root.as_deref()) + .map_err(to_mcp_error)?; + let report = devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( + &input.tsx, + theme_lookup.theme.as_ref(), + input.strict, + ); + Ok(tool_result(json!({ + "ok": report.ok, + "violations": report.violations, + "checkedTokens": report.checked_tokens, + "availableTokenCount": report.available_token_count, + "themeAvailable": theme_lookup.theme.is_some(), + "themeGuardrail": theme_lookup.guardrail, + }))) + } + + #[tool( + description = "Detect drift across the devup stack (vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client); layers: db-entity | entity-route | route-openapi | openapi-client, omit for all. Text/JSON-based heuristics, not a compiler — every finding carries an explicit confidence", + output_schema = permissive_object_output_schema() + )] + async fn devup_stack_diff( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = stack_diff::run(input.project_root.as_deref(), &input.layers) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } } fn section_index_from_payload(payload: &CollectedPayload) -> Option { diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs new file mode 100644 index 0000000..18586e5 --- /dev/null +++ b/crates/devup-mcp/src/server/project_context.rs @@ -0,0 +1,641 @@ +//! `devup_project_context` — the ground-truth reader. Reads a project's +//! real `devup.json` (theme tokens), `openapi.json` (endpoints/schemas), +//! and Vespertide `models/*.json` (database tables/columns) so an agent +//! never has to guess what identifiers a project actually has. +//! +//! Every scope reads its target file(s) fresh on every call (no session +//! cache — see `project_root.rs`'s module docs) and, when a target file is +//! missing, returns the shared `{"found":false,"guardrail":{...}}` +//! envelope rather than an empty/ambiguous success. + +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::theme::parse_project_theme; +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use super::project_root::{ + PROJECT_ROOT_NOT_FOUND_MESSAGE, display_path, find_dirs_named, find_files_named, + find_project_root, guardrail_object, json_files_in, not_found_response, +}; + +/// A project's `devup.json` theme, resolved for `devup_ui_validate` — or, +/// when unavailable, the same `{"found":false,"guardrail":{...}}` shape +/// `devup_project_context` would have returned, surfaced under a distinct +/// key so callers can tell "no theme was available, token checks were +/// skipped" apart from "every $token check passed". +pub struct ThemeLookup { + pub theme: Option, + pub guardrail: Option, +} + +/// Resolves the theme `devup_ui_validate` should check `$token` references +/// against: the project root's own `devup.json` if present, otherwise the +/// first `devup.json` found within the project (bounded search), otherwise +/// `None` with an explanatory guardrail. Never caches: reads fresh on every +/// call, per this module's no-session-cache requirement. +pub fn theme_for_validation(project_root: Option<&str>) -> Result { + let start = match project_root { + Some(root) => PathBuf::from(root), + None => std::env::current_dir().map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "현재 디렉터리를 확인하지 못했습니다.", + false, + json!({ "ioError": error.to_string() }), + ) + })?, + }; + let Some(root) = find_project_root(&start) else { + return Ok(ThemeLookup { + theme: None, + guardrail: Some(guardrail_object( + PROJECT_ROOT_NOT_FOUND_MESSAGE, + vec![display_path(&start)], + )), + }); + }; + let root_level = root.join("devup.json"); + let file = if root_level.is_file() { + Some(root_level) + } else { + find_files_named(&root, "devup.json", 4).into_iter().next() + }; + let Some(file) = file else { + return Ok(ThemeLookup { + theme: None, + guardrail: Some(guardrail_object( + "devup.json을 찾지 못했습니다. $token 참조를 검증할 수 없어 unknown-token 검사를 건너뜁니다. 존재하지 않는 토큰을 추측해서 사용하지 마세요.", + vec![display_path(&root.join("devup.json"))], + )), + }); + }; + let source = std::fs::read_to_string(&file).map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "devup.json을 읽지 못했습니다.", + false, + json!({ "path": display_path(&file), "ioError": error.to_string() }), + ) + })?; + let theme = parse_project_theme(&source)?; + Ok(ThemeLookup { + theme: Some(theme), + guardrail: None, + }) +} + +pub async fn run( + scope: &str, + project_root: Option<&str>, + filter: Option<&str>, +) -> Result { + if !["theme", "api", "db", "all"].contains(&scope) { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "scope는 theme, api, db 또는 all이어야 합니다.", + false, + )); + } + let start = match project_root { + Some(root) => PathBuf::from(root), + None => std::env::current_dir().map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "현재 디렉터리를 확인하지 못했습니다.", + false, + json!({ "ioError": error.to_string() }), + ) + })?, + }; + let Some(root) = find_project_root(&start) else { + return Ok(not_found_response( + PROJECT_ROOT_NOT_FOUND_MESSAGE, + vec![display_path(&start)], + )); + }; + + match scope { + "theme" => Ok(theme_scope(&root, filter)), + "api" => Ok(api_scope(&root, filter)), + "db" => Ok(db_scope(&root, filter)), + "all" => { + let mut all = Map::new(); + all.insert("found".to_owned(), Value::Bool(true)); + all.insert("projectRoot".to_owned(), json!(display_path(&root))); + all.insert("theme".to_owned(), theme_scope(&root, filter)); + all.insert("api".to_owned(), api_scope(&root, filter)); + all.insert("db".to_owned(), db_scope(&root, filter)); + Ok(Value::Object(all)) + } + _ => unreachable!("scope validated above"), + } +} + +// --------------------------------------------------------------------- +// theme scope +// --------------------------------------------------------------------- + +fn theme_scope(root: &Path, filter: Option<&str>) -> Value { + let mut files = find_files_named(root, "devup.json", 4); + if !root.join("devup.json").is_file() { + // find_files_named already includes root/devup.json if present via + // the breadth-first walk starting at root itself; this branch only + // guards against a root walk that (by construction) never omits + // depth-0 files, kept as a defensive no-op. + } + files.sort(); + files.dedup(); + if files.is_empty() { + return not_found_response( + "devup.json을 찾지 못했습니다. 색상·타이포그래피·길이·그림자 토큰 이름을 추측해서 코드를 작성하지 마세요.", + vec![display_path(&root.join("devup.json"))], + ); + } + let mut projects = Vec::new(); + for file in &files { + let relative = relative_display(root, file); + let source = match std::fs::read_to_string(file) { + Ok(source) => source, + Err(error) => { + projects.push(json!({ + "path": relative, + "readError": error.to_string() + })); + continue; + } + }; + let theme = match parse_project_theme(&source) { + Ok(theme) => theme, + Err(error) => { + projects.push(json!({ + "path": relative, + "parseError": error.message + })); + continue; + } + }; + let modes = theme.modes(); + let matches_filter = |name: &str| filter.is_none_or(|needle| name.contains(needle)); + let colors = filtered_mode_map(&theme.colors, matches_filter); + let length = filtered_mode_map(&theme.length, matches_filter); + let shadow = filtered_mode_map(&theme.shadow, matches_filter); + let typography = theme + .typography + .iter() + .filter(|(name, _)| matches_filter(name)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + projects.push(json!({ + "path": relative, + "modes": modes, + "tokenCount": theme.token_count(), + "colors": colors, + "typography": typography, + "length": length, + "shadow": shadow, + })); + } + json!({ + "found": true, + "scope": "theme", + "projectRoot": display_path(root), + "files": projects, + }) +} + +fn filtered_mode_map( + map: &std::collections::BTreeMap>, + matches_filter: impl Fn(&str) -> bool, +) -> Map { + map.iter() + .map(|(mode, tokens)| { + let tokens = tokens + .iter() + .filter(|(name, _)| matches_filter(name)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + (mode.clone(), Value::Object(tokens)) + }) + .collect() +} + +// --------------------------------------------------------------------- +// api scope +// --------------------------------------------------------------------- + +const HTTP_METHODS: &[&str] = &[ + "get", "post", "put", "patch", "delete", "head", "options", "trace", +]; + +fn api_scope(root: &Path, filter: Option<&str>) -> Value { + let files = find_files_named(root, "openapi.json", 4); + if files.is_empty() { + return not_found_response( + "openapi.json을 찾지 못했습니다. API 엔드포인트나 스키마 이름을 추측해서 코드를 작성하지 마세요.", + vec![format!("{} (up to depth 4)", display_path(root))], + ); + } + let mut specs = Vec::new(); + for file in &files { + let relative = relative_display(root, file); + let source = match std::fs::read_to_string(file) { + Ok(source) => source, + Err(error) => { + specs.push(json!({ "path": relative, "readError": error.to_string() })); + continue; + } + }; + let parsed: Value = match serde_json::from_str(&source) { + Ok(value) => value, + Err(error) => { + specs.push(json!({ "path": relative, "parseError": error.to_string() })); + continue; + } + }; + specs.push(project_openapi_spec(&relative, &parsed, filter)); + } + json!({ + "found": true, + "scope": "api", + "projectRoot": display_path(root), + "specs": specs, + }) +} + +fn project_openapi_spec(relative_path: &str, spec: &Value, filter: Option<&str>) -> Value { + let matches_filter = |haystack: &str| filter.is_none_or(|needle| haystack.contains(needle)); + let mut endpoints = Vec::new(); + if let Some(paths) = spec.get("paths").and_then(Value::as_object) { + for (path, methods) in paths { + let Some(methods) = methods.as_object() else { + continue; + }; + for method in HTTP_METHODS { + let Some(operation) = methods.get(*method) else { + continue; + }; + let operation_id = operation.get("operationId").and_then(Value::as_str); + let haystack = format!("{path} {} {}", method, operation_id.unwrap_or("")); + if !matches_filter(&haystack) { + continue; + } + endpoints.push(json!({ + "method": method.to_ascii_uppercase(), + "path": path, + "operationId": operation_id, + })); + } + } + } + let mut schemas = Vec::new(); + let schema_container = spec + .get("components") + .and_then(|components| components.get("schemas")) + .or_else(|| spec.get("definitions")); // OpenAPI 2 / Swagger fallback + if let Some(Value::Object(schema_map)) = schema_container { + for (name, schema) in schema_map { + if !matches_filter(name) { + continue; + } + let required = schema + .get("required") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .map(|props| props.keys().cloned().collect::>()) + .unwrap_or_default(); + schemas.push(json!({ + "name": name, + "requiredFields": required, + "properties": properties, + })); + } + } + json!({ + "path": relative_path, + "endpointCount": endpoints.len(), + "schemaCount": schemas.len(), + "endpoints": endpoints, + "schemas": schemas, + }) +} + +// --------------------------------------------------------------------- +// db scope (Vespertide models) +// --------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct VespertideModel { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + columns: Vec, +} + +#[derive(Debug, Deserialize)] +struct VespertideColumn { + name: String, + #[serde(rename = "type")] + column_type: Value, + #[serde(default)] + nullable: bool, + #[serde(default)] + primary_key: Option, + #[serde(default)] + unique: Option, + #[serde(default)] + foreign_key: Option, + #[serde(default)] + index: Option, + #[serde(default)] + comment: Option, +} + +fn db_scope(root: &Path, filter: Option<&str>) -> Value { + let model_dirs = find_dirs_named(root, "models", 4); + let mut model_files = Vec::new(); + for dir in &model_dirs { + model_files.extend(json_files_in(dir)); + } + model_files.sort(); + model_files.dedup(); + if model_files.is_empty() { + return not_found_response( + "Vespertide 모델(models/*.json)을 찾지 못했습니다. 테이블·컬럼 이름이나 타입을 추측해서 코드를 작성하지 마세요.", + vec![format!( + "{} (models/*.json, up to depth 4)", + display_path(root) + )], + ); + } + let matches_filter = |haystack: &str| filter.is_none_or(|needle| haystack.contains(needle)); + let mut tables = Vec::new(); + for file in &model_files { + let relative = relative_display(root, file); + let source = match std::fs::read_to_string(file) { + Ok(source) => source, + Err(error) => { + tables.push(json!({ "path": relative, "readError": error.to_string() })); + continue; + } + }; + let model: VespertideModel = match serde_json::from_str(&source) { + Ok(model) => model, + Err(error) => { + // Not every *.json in a `models/` directory is necessarily a + // Vespertide model (e.g. `vespertide.json` config sitting + // one level up would not match this dir name, but a stray + // non-model JSON inside `models/` itself would land here). + // Report the parse failure rather than silently skipping, + // so the caller can see exactly why a file didn't surface. + tables.push(json!({ "path": relative, "parseError": error.to_string() })); + continue; + } + }; + if !matches_filter(&model.name) { + continue; + } + let columns = model.columns.iter().map(column_to_json).collect::>(); + let enums = model + .columns + .iter() + .filter_map(enum_definition) + .collect::>(); + tables.push(json!({ + "path": relative, + "table": model.name, + "description": model.description, + "columns": columns, + "enums": enums, + })); + } + json!({ + "found": true, + "scope": "db", + "projectRoot": display_path(root), + "tables": tables, + }) +} + +fn column_to_json(column: &VespertideColumn) -> Value { + let (type_name, enum_values) = describe_column_type(&column.column_type); + json!({ + "name": column.name, + "type": type_name, + "nullable": column.nullable, + "primaryKey": column.primary_key.is_some(), + "unique": column.unique.is_some(), + "indexed": column.index.is_some(), + "foreignKey": column.foreign_key, + "enumValues": enum_values, + "comment": column.comment, + }) +} + +fn enum_definition(column: &VespertideColumn) -> Option { + let object = column.column_type.as_object()?; + if object.get("kind").and_then(Value::as_str) != Some("enum") { + return None; + } + Some(json!({ + "column": column.name, + "name": object.get("name"), + "values": object.get("values").cloned().unwrap_or(Value::Null), + })) +} + +/// Returns `(type_name, enum_values)`: for simple string types, the string +/// itself with no enum values; for complex `{"kind": ..., ...}` types, the +/// `kind` string, and — for `kind: "enum"` — the raw `values` array. +fn describe_column_type(column_type: &Value) -> (String, Option) { + match column_type { + Value::String(simple) => (simple.clone(), None), + Value::Object(object) => { + let kind = object + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + let enum_values = if kind == "enum" { + object.get("values").cloned() + } else { + None + }; + (kind, enum_values) + } + other => (other.to_string(), None), + } +} + +fn relative_display(root: &Path, file: &Path) -> String { + file.strip_prefix(root) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| display_path(file)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct ScopedTempDir(PathBuf); + + impl ScopedTempDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devup-mcp-context-test-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create scoped temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for ScopedTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[tokio::test] + async fn theme_scope_reads_real_devup_json_tokens() { + let temp = ScopedTempDir::new("theme-ok"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write( + temp.path().join("devup.json"), + r##"{ "theme": { "colors": { "default": { "captionLight": "#999999" } } } }"##, + ) + .unwrap(); + let result = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + assert_eq!( + result["files"][0]["colors"]["default"]["captionLight"], + "#999999" + ); + } + + #[tokio::test] + async fn theme_scope_reports_not_found_guardrail_without_devup_json() { + let temp = ScopedTempDir::new("theme-missing"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let result = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], false); + assert_eq!(result["guardrail"]["action"], "stop-and-report"); + } + + #[tokio::test] + async fn missing_project_root_reports_guardrail() { + let temp = ScopedTempDir::new("no-root"); + // No package.json/devup.json/Cargo.toml/.git anywhere under temp. + let nested = temp.path().join("deep").join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + let result = run("theme", Some(&nested.to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], false); + assert_eq!(result["guardrail"]["action"], "stop-and-report"); + } + + #[tokio::test] + async fn api_scope_extracts_endpoints_and_required_fields() { + let temp = ScopedTempDir::new("api-ok"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write( + temp.path().join("openapi.json"), + r##"{ + "paths": { + "/users/{id}": { + "get": { "operationId": "getUser" } + } + }, + "components": { + "schemas": { + "User": { "required": ["id", "email"], "properties": { "id": {}, "email": {}, "name": {} } } + } + } + }"##, + ) + .unwrap(); + let result = run("api", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + assert_eq!(result["specs"][0]["endpoints"][0]["operationId"], "getUser"); + assert_eq!(result["specs"][0]["endpoints"][0]["method"], "GET"); + assert_eq!(result["specs"][0]["schemas"][0]["requiredFields"][0], "id"); + } + + #[tokio::test] + async fn db_scope_extracts_columns_and_enum_values() { + let temp = ScopedTempDir::new("db-ok"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let models = temp.path().join("apis").join("api").join("models"); + std::fs::create_dir_all(&models).unwrap(); + std::fs::write( + models.join("user.json"), + r##"{ + "name": "user", + "columns": [ + { "name": "id", "type": "uuid", "nullable": false, "primary_key": true }, + { "name": "status", "type": { "kind": "enum", "name": "user_status", "values": ["pending", "active"] }, "nullable": false } + ] + }"##, + ) + .unwrap(); + let result = run("db", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + let table = &result["tables"][0]; + assert_eq!(table["table"], "user"); + assert_eq!(table["columns"][0]["name"], "id"); + assert_eq!(table["columns"][0]["primaryKey"], true); + assert_eq!(table["enums"][0]["values"][0], "pending"); + } + + #[tokio::test] + async fn invalid_scope_is_rejected() { + let temp = ScopedTempDir::new("bad-scope"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let error = run("bogus", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + } + + #[tokio::test] + async fn all_scope_combines_every_axis() { + let temp = ScopedTempDir::new("all-scope"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write(temp.path().join("devup.json"), r##"{"theme":{}}"##).unwrap(); + let result = run("all", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], true); + assert!(result.get("theme").is_some()); + assert!(result.get("api").is_some()); + assert!(result.get("db").is_some()); + } +} diff --git a/crates/devup-mcp/src/server/project_root.rs b/crates/devup-mcp/src/server/project_root.rs new file mode 100644 index 0000000..81f7cca --- /dev/null +++ b/crates/devup-mcp/src/server/project_root.rs @@ -0,0 +1,248 @@ +//! Shared project-root discovery and the "stop-and-report" guardrail +//! response shape used by all three ground-truth tools +//! (`devup_project_context`, `devup_ui_validate`, `devup_stack_diff`). +//! +//! This generalizes the exact pattern verified in `diagnostics::host_requirement` +//! for the `needs_figma` handoff: when a tool cannot ground its answer in a +//! real file, it must say so explicitly and instruct the caller to stop +//! rather than guess, instead of silently returning nothing or (worse) +//! inventing a plausible-looking answer. See `README.md`'s brief for the +//! `$gray100` incident this exists to prevent. +//! +//! Every function here only reads the filesystem; nothing is written or +//! cached across calls, per the brief's "호출 시점에 파일을 읽는다. 세션 +//! 간 캐시 금지" requirement — a project file can change between two +//! tool calls in the same session, and treating a stale in-memory copy as +//! current fact would be exactly the kind of confident-but-wrong answer +//! this tool exists to prevent. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +/// Filenames whose presence in a directory marks it as a project root. +const ROOT_MARKERS: &[&str] = &["devup.json", "package.json", "Cargo.toml", ".git"]; + +/// Directory names never descended into during a bounded project search: +/// dependency/build output that is large, irrelevant, and would otherwise +/// dominate search time and result noise. +const SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + "dist", + "build", + ".git", + ".next", + ".turbo", + ".nuxt", + "out", + ".venv", + "venv", + "__pycache__", + ".cache", + "coverage", +]; + +/// Searches `start` and each ancestor directory for one of [`ROOT_MARKERS`], +/// returning the first (nearest) directory that has one. Returns `None` if +/// no ancestor (up to the filesystem root) has any marker. +pub fn find_project_root(start: &Path) -> Option { + let mut current = Some(start.to_path_buf()); + while let Some(dir) = current { + if ROOT_MARKERS.iter().any(|marker| dir.join(marker).exists()) { + return Some(dir); + } + current = dir.parent().map(Path::to_path_buf); + } + None +} + +/// Breadth-first search from `root` down to `max_depth` directories for +/// every file whose name is exactly `filename`, skipping [`SKIP_DIRS`]. +/// Returns paths sorted for deterministic output. +pub fn find_files_named(root: &Path, filename: &str, max_depth: usize) -> Vec { + let mut found = Vec::new(); + let mut queue = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() && name == filename { + found.push(path); + } else if file_type.is_dir() && depth < max_depth && !SKIP_DIRS.contains(&name.as_ref()) + { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +/// Breadth-first search for every directory named exactly `dirname` (e.g. +/// vespertide's conventional `models/` directory), skipping [`SKIP_DIRS`]. +pub fn find_dirs_named(root: &Path, dirname: &str, max_depth: usize) -> Vec { + let mut found = Vec::new(); + let mut queue = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == dirname { + found.push(path.clone()); + } + if depth < max_depth && !SKIP_DIRS.contains(&name.as_ref()) { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +/// Every `*.json` file directly inside `dir` (non-recursive), sorted. +pub fn json_files_in(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) + .collect::>(); + files.sort(); + files +} + +/// Just the `guardrail` object (`{"action": "stop-and-report", ...}`), +/// without the `found` wrapper — for tools that need to embed it as a +/// nested field (e.g. `devup_ui_validate`'s `themeGuardrail`) rather than +/// as the whole top-level response. `action` is always the literal string +/// `"stop-and-report"`, the same contract +/// [`crate::server::host_requirement`]-style responses use. +pub fn guardrail_object(message: impl Into, searched_paths: Vec) -> Value { + json!({ + "action": "stop-and-report", + "message": message.into(), + "searchedPaths": searched_paths + }) +} + +/// The `{"found": false, "guardrail": {...}}` envelope every ground-truth +/// tool returns as its top-level response instead of guessing when it +/// cannot locate the file(s) it needs. +pub fn not_found_response(message: impl Into, searched_paths: Vec) -> Value { + json!({ + "found": false, + "guardrail": guardrail_object(message, searched_paths) + }) +} + +/// The standard message for "could not even determine a project root" — +/// distinct from "found a project root but the target file is missing" +/// ([`not_found_response`] with a scope-specific message), since the two +/// failures call for different next steps from the caller. +pub const PROJECT_ROOT_NOT_FOUND_MESSAGE: &str = "프로젝트 루트를 찾지 못했습니다. devup.json, package.json, Cargo.toml, .git 중 하나가 있는 디렉터리를 찾지 못했습니다. 토큰·엔드포인트·컬럼 이름을 추측해서 코드를 작성하지 마세요."; + +/// Path displayed as-is (already OS-native), used consistently across the +/// three tools so `searchedPaths` entries are directly copy-pasteable. +pub fn display_path(path: &Path) -> String { + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Minimal scoped-temp-directory helper (no `tempfile` dependency): + /// creates a uniquely-named directory under the OS temp dir and removes + /// it (and everything under it) on drop. + struct ScopedTempDir(PathBuf); + + impl ScopedTempDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devup-mcp-test-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create scoped temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for ScopedTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn finds_root_by_walking_up_to_a_marker() { + let temp = ScopedTempDir::new("root-marker"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let nested = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&nested).unwrap(); + let root = find_project_root(&nested).expect("root found"); + assert_eq!(root, temp.path()); + } + + #[test] + fn returns_none_when_no_marker_exists_up_to_a_bare_temp_dir() { + let temp = ScopedTempDir::new("no-marker"); + let isolated = temp.path().join("isolated"); + std::fs::create_dir_all(&isolated).unwrap(); + // A bare scoped temp dir has no devup.json/package.json/Cargo.toml/.git + // in the isolated subtree itself, which is what we control + // deterministically here. + assert!( + !ROOT_MARKERS + .iter() + .any(|marker| isolated.join(marker).exists()) + ); + } + + #[test] + fn find_files_named_skips_node_modules() { + let temp = ScopedTempDir::new("skip-node-modules"); + let nm = temp.path().join("node_modules").join("pkg"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write(nm.join("devup.json"), "{}").unwrap(); + let real = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("devup.json"), "{}").unwrap(); + let found = find_files_named(temp.path(), "devup.json", 4); + assert_eq!(found, vec![real.join("devup.json")]); + } + + #[test] + fn not_found_response_always_has_stop_and_report_action() { + let value = not_found_response("test", vec!["a".to_owned()]); + assert_eq!(value["found"], false); + assert_eq!(value["guardrail"]["action"], "stop-and-report"); + assert_eq!(value["guardrail"]["searchedPaths"][0], "a"); + } +} diff --git a/crates/devup-mcp/src/server/stack_diff.rs b/crates/devup-mcp/src/server/stack_diff.rs new file mode 100644 index 0000000..eb042c3 --- /dev/null +++ b/crates/devup-mcp/src/server/stack_diff.rs @@ -0,0 +1,1056 @@ +//! `devup_stack_diff` — cross-layer drift detection across the devup +//! stack (`vespertide model -> sea-orm entity -> vespera route -> +//! openapi.json -> @devup-api client`). This is the one ground-truth tool +//! that cannot be reduced to "read one file and report its contents": it +//! compares independently-authored layers that a human reviewer would +//! normally have to cross-reference by hand. +//! +//! Every check here is text/JSON-based, not a real compiler front end for +//! Rust or TypeScript. That is a deliberate, disclosed limitation, not an +//! oversight: extraction can miss macro-generated routes (e.g. +//! `vespera::export_app!`-merged sub-apps), non-standard formatting, or +//! re-exported client wrappers. Every reported drift and every skipped +//! layer carries an explicit `confidence` (`"low"` or `"medium"`) — never +//! `"high"`, since none of these checks is a real parse — and the tool +//! never claims a clean layer is drift-free with unwarranted certainty; +//! see each layer's doc comment for exactly what it can and cannot see. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde_json::{Value, json}; + +use super::project_root::{ + PROJECT_ROOT_NOT_FOUND_MESSAGE, display_path, find_dirs_named, find_files_named, + find_project_root, json_files_in, not_found_response, +}; + +const ALL_LAYERS: &[&str] = &[ + "db-entity", + "entity-route", + "route-openapi", + "openapi-client", +]; + +pub async fn run(project_root: Option<&str>, layers: &[String]) -> Result { + let requested = if layers.is_empty() { + ALL_LAYERS + .iter() + .map(|layer| (*layer).to_owned()) + .collect::>() + } else { + layers.to_vec() + }; + for layer in &requested { + if !ALL_LAYERS.contains(&layer.as_str()) { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "layers 항목은 db-entity, entity-route, route-openapi, openapi-client 중 하나여야 합니다.", + false, + json!({ "invalidLayer": layer }), + )); + } + } + + let start = match project_root { + Some(root) => PathBuf::from(root), + None => std::env::current_dir().map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "현재 디렉터리를 확인하지 못했습니다.", + false, + json!({ "ioError": error.to_string() }), + ) + })?, + }; + let Some(root) = find_project_root(&start) else { + return Ok(not_found_response( + PROJECT_ROOT_NOT_FOUND_MESSAGE, + vec![display_path(&start)], + )); + }; + + let model_dirs = find_dirs_named(&root, "models", 5); + let mut layers_out = serde_json::Map::new(); + for layer in &requested { + let result = match layer.as_str() { + "db-entity" => db_entity_layer(&model_dirs), + "entity-route" => entity_route_layer(&root, &model_dirs), + "route-openapi" => route_openapi_layer(&root), + "openapi-client" => openapi_client_layer(&root), + _ => unreachable!("validated above"), + }; + layers_out.insert(layer.clone(), result); + } + + Ok(json!({ + "found": true, + "projectRoot": display_path(&root), + "layers": Value::Object(layers_out), + })) +} + +// --------------------------------------------------------------------- +// db-entity: vespertide models/*.json columns vs sea-orm src/models/*.rs +// --------------------------------------------------------------------- + +/// Compares each Vespertide model's declared columns against the field +/// names in its generated sea-orm `Model` struct +/// (`/src/models/.rs`, per `vespertide.json`'s +/// default `modelExportDir`). Field extraction is a brace-depth text scan +/// for `pub struct Model { ... }`, not a Rust parser, so it can miss +/// fields hidden behind `#[cfg(...)]` or unusual formatting — hence +/// `confidence: "medium"` rather than `"high"`. +fn db_entity_layer(model_dirs: &[PathBuf]) -> Value { + if model_dirs.is_empty() { + return json!({ + "checked": false, + "reason": "models/ 디렉터리를 찾지 못했습니다 (Vespertide 모델 없음).", + "drifts": [], + }); + } + let mut drifts = Vec::new(); + let mut tables_checked = 0usize; + for models_dir in model_dirs { + let vespertide_root = models_dir.parent().map(Path::to_path_buf); + for model_file in json_files_in(models_dir) { + let Ok(source) = std::fs::read_to_string(&model_file) else { + continue; + }; + let Ok(model) = serde_json::from_str::(&source) else { + continue; + }; + let Some(table) = model.get("name").and_then(Value::as_str) else { + continue; + }; + let column_names = model + .get("columns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|column| column.get("name").and_then(Value::as_str)) + .map(str::to_owned) + .collect::>(); + if column_names.is_empty() { + continue; + } + tables_checked += 1; + let Some(vespertide_root) = &vespertide_root else { + continue; + }; + let entity_path = vespertide_root + .join("src") + .join("models") + .join(format!("{table}.rs")); + let Ok(entity_source) = std::fs::read_to_string(&entity_path) else { + drifts.push(json!({ + "table": table, + "kind": "entity-not-generated", + "message": format!( + "{table} 모델에 대응하는 sea-orm entity({})를 찾지 못했습니다. `vespertide export --orm seaorm`을 실행했는지 확인하세요.", + display_path(&entity_path) + ), + "confidence": "low", + })); + continue; + }; + let entity_fields = extract_model_struct_fields(&entity_source); + let missing_in_entity = column_names + .difference(&entity_fields) + .cloned() + .collect::>(); + let missing_in_model = entity_fields + .difference(&column_names) + .cloned() + .collect::>(); + if !missing_in_entity.is_empty() || !missing_in_model.is_empty() { + drifts.push(json!({ + "table": table, + "kind": "column-entity-mismatch", + "entityPath": display_path(&entity_path), + "columnsMissingInEntity": missing_in_entity, + "fieldsMissingInModel": missing_in_model, + "confidence": "medium", + })); + } + } + } + json!({ + "checked": true, + "tablesChecked": tables_checked, + "drifts": drifts, + }) +} + +/// Text-scans a sea-orm entity source for `pub struct Model { ... }` and +/// extracts each `pub : ,` line's field name via brace-depth +/// tracking (not a real Rust parser). +fn extract_model_struct_fields(source: &str) -> BTreeSet { + let mut fields = BTreeSet::new(); + let Some(struct_start) = source.find("struct Model") else { + return fields; + }; + let Some(open_brace_offset) = source[struct_start..].find('{') else { + return fields; + }; + let body_start = struct_start + open_brace_offset + 1; + let mut depth = 1i32; + let mut end = body_start; + for (offset, character) in source[body_start..].char_indices() { + match character { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = body_start + offset; + break; + } + } + _ => {} + } + } + let body = &source[body_start..end]; + for line in body.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("pub ") else { + continue; + }; + let Some(colon) = rest.find(':') else { + continue; + }; + let field_name = rest[..colon].trim(); + if !field_name.is_empty() && field_name.chars().all(|c| c.is_alphanumeric() || c == '_') { + fields.insert(field_name.to_owned()); + } + } + fields +} + +// --------------------------------------------------------------------- +// entity-route: does any route file even mention each entity field? +// --------------------------------------------------------------------- + +/// For each Vespertide column, checks whether its snake_case name or its +/// PascalCase sea-orm `Column::Variant` form appears as a plain substring +/// anywhere under a sibling `src/routes/` tree. This is a *presence* +/// check, not a semantic one: a column could appear in a comment, an +/// unrelated string, or a route that never actually serializes it, and a +/// column genuinely unused by any route (by design, e.g. an internal-only +/// audit column) will still be flagged. `confidence: "low"` reflects this; +/// treat every reported item as a lead to verify, not a confirmed bug. +fn entity_route_layer(root: &Path, model_dirs: &[PathBuf]) -> Value { + if model_dirs.is_empty() { + return json!({ + "checked": false, + "reason": "models/ 디렉터리를 찾지 못했습니다 (Vespertide 모델 없음).", + "drifts": [], + }); + } + let mut drifts = Vec::new(); + let mut columns_checked = 0usize; + for models_dir in model_dirs { + let Some(vespertide_root) = models_dir.parent() else { + continue; + }; + let routes_dir = vespertide_root.join("src").join("routes"); + let route_sources = collect_rust_sources(&routes_dir, 6) + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .collect::>(); + if route_sources.is_empty() { + drifts.push(json!({ + "kind": "no-routes-dir", + "message": format!( + "{}에 라우트 파일이 없어 entity-route 대응을 확인할 수 없습니다.", + display_path(&routes_dir) + ), + "confidence": "low", + })); + continue; + } + for model_file in json_files_in(models_dir) { + let Ok(source) = std::fs::read_to_string(&model_file) else { + continue; + }; + let Ok(model) = serde_json::from_str::(&source) else { + continue; + }; + let Some(table) = model.get("name").and_then(Value::as_str) else { + continue; + }; + for column in model + .get("columns") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(column_name) = column.get("name").and_then(Value::as_str) else { + continue; + }; + columns_checked += 1; + let pascal = snake_to_pascal(column_name); + let mentioned = route_sources + .iter() + .any(|source| source.contains(column_name) || source.contains(&pascal)); + if !mentioned { + drifts.push(json!({ + "table": table, + "column": column_name, + "kind": "column-never-referenced-in-routes", + "message": format!( + "{table}.{column_name}을(를) 참조하는 라우트를 찾지 못했습니다. 의도적으로 내부 전용 컬럼일 수 있습니다." + ), + "confidence": "low", + })); + } + } + } + } + let _ = root; // reserved for future cross-app route roots; kept explicit rather than unused + json!({ + "checked": true, + "columnsChecked": columns_checked, + "drifts": drifts, + }) +} + +fn snake_to_pascal(input: &str) -> String { + input + .split('_') + .filter(|segment| !segment.is_empty()) + .map(|segment| { + let mut chars = segment.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } + }) + .collect() +} + +fn collect_rust_sources(dir: &Path, max_depth: usize) -> Vec { + find_files_by_extension(dir, "rs", max_depth) +} + +fn find_files_by_extension(dir: &Path, extension: &str, max_depth: usize) -> Vec { + let mut found = Vec::new(); + if !dir.is_dir() { + return found; + } + let mut queue = vec![(dir.to_path_buf(), 0usize)]; + const SKIP: &[&str] = &["node_modules", "target", "dist", "build", ".git", ".next"]; + while let Some((current, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() + && path.extension().and_then(|ext| ext.to_str()) == Some(extension) + { + found.push(path); + } else if file_type.is_dir() && depth < max_depth && !SKIP.contains(&name.as_ref()) { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +// --------------------------------------------------------------------- +// route-openapi: #[vespera::route(...)] handlers vs openapi.json paths +// --------------------------------------------------------------------- + +/// Scans every `.rs` file under each `src/routes/` tree found in the +/// project for `#[vespera::route( [, path = "..."])]` attributes, +/// derives each handler's URL from Vespera's documented file-structure +/// convention (`src/routes/users.rs` -> `/users`, `src/routes/admin/mod.rs` +/// -> `/admin`, `path = "/{id}"` appended), and compares the resulting +/// `(METHOD, path)` set against `openapi.json`'s `paths`. Attribute +/// extraction is a bracket-balanced text scan for the macro call, not a +/// real Rust/proc-macro parse, so multi-app merges +/// (`vespera::export_app!`/`merge = [...]`) and non-standard route-macro +/// formatting can produce false positives — `confidence: "medium"`. +fn route_openapi_layer(root: &Path) -> Value { + let routes_dirs = find_dirs_named(root, "routes", 5) + .into_iter() + .filter(|dir| dir.join("mod.rs").is_file() || !collect_rust_sources(dir, 0).is_empty()) + .collect::>(); + let openapi_files = find_files_named(root, "openapi.json", 4); + if routes_dirs.is_empty() && openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "src/routes/도, openapi.json도 찾지 못했습니다.", + "drifts": [], + }); + } + + let mut code_routes = BTreeSet::<(String, String)>::new(); + for routes_dir in &routes_dirs { + for file in collect_rust_sources(routes_dir, 6) { + let Ok(source) = std::fs::read_to_string(&file) else { + continue; + }; + let Ok(relative) = file.strip_prefix(routes_dir) else { + continue; + }; + let prefix = route_url_prefix(relative); + for (method, path_attr) in extract_vespera_route_attributes(&source) { + let url = join_route_url(&prefix, path_attr.as_deref()); + code_routes.insert((method.to_ascii_uppercase(), url)); + } + } + } + + let mut spec_routes = BTreeSet::<(String, String)>::new(); + let mut specs_checked = Vec::new(); + for file in &openapi_files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + let Ok(spec) = serde_json::from_str::(&source) else { + continue; + }; + specs_checked.push(display_path(file)); + for (method, path) in extract_openapi_path_methods(&spec) { + spec_routes.insert((method, path)); + } + } + + if routes_dirs.is_empty() { + return json!({ + "checked": false, + "reason": "src/routes/를 찾지 못해 코드 쪽 라우트를 확인할 수 없습니다.", + "openapiSpecsFound": specs_checked, + "drifts": [], + }); + } + if openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "openapi.json을 찾지 못해 스펙과 비교할 수 없습니다.", + "codeRoutesFound": code_routes.len(), + "drifts": [], + }); + } + + let stale_spec = code_routes + .difference(&spec_routes) + .map(|(method, path)| json!({ "method": method, "path": path })) + .collect::>(); + let stale_code_or_merged = spec_routes + .difference(&code_routes) + .map(|(method, path)| json!({ "method": method, "path": path })) + .collect::>(); + + let mut drifts = Vec::new(); + if !stale_spec.is_empty() { + drifts.push(json!({ + "kind": "route-missing-from-openapi", + "message": "코드에 있는 라우트가 openapi.json에 없습니다. 스펙이 낡았을 수 있습니다 (재빌드 필요).", + "routes": stale_spec, + "confidence": "medium", + })); + } + if !stale_code_or_merged.is_empty() { + drifts.push(json!({ + "kind": "openapi-path-not-found-in-scanned-routes", + "message": "openapi.json에 있는 경로를 스캔한 라우트 파일에서 찾지 못했습니다. merge된 하위 앱이거나 라우트 매크로 형식이 달라 스캔이 놓쳤을 수 있습니다.", + "routes": stale_code_or_merged, + "confidence": "low", + })); + } + + json!({ + "checked": true, + "codeRouteCount": code_routes.len(), + "openapiRouteCount": spec_routes.len(), + "openapiSpecsFound": specs_checked, + "drifts": drifts, + }) +} + +/// Extracts `(method, path_attribute)` pairs from every +/// `#[vespera::route(...)]` (or `#[route(...)]` when `vespera::route` is +/// imported directly) attribute in `source`, matched to the very next +/// `pub async fn` per Vespera's "route handlers MUST be `pub async fn`" +/// requirement — attributes not immediately followed by one are ignored. +fn extract_vespera_route_attributes(source: &str) -> Vec<(String, Option)> { + let mut results = Vec::new(); + let mut search_from = 0usize; + while let Some(relative) = source[search_from..].find("route(") { + let start = search_from + relative; + // Require this `route(` to be a `#[...route(` attribute, not an + // unrelated identifier ending in `route`. `start` points at the + // `r` of `route(`, so the text immediately preceding it is either + // `::` (`#[vespera::route(`) or `[`/whitespace (`#[route(`). + let before = source[..start].trim_end(); + if !before.ends_with("::") && !before.ends_with('[') { + search_from = start + "route(".len(); + continue; + } + let Some(open_paren) = source[start..].find('(') else { + break; + }; + let args_start = start + open_paren + 1; + let mut depth = 1i32; + let mut args_end = args_start; + for (offset, character) in source[args_start..].char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + args_end = args_start + offset; + break; + } + } + _ => {} + } + } + let args = &source[args_start..args_end]; + search_from = args_end + 1; + let Some(method) = args + .split(',') + .next() + .map(str::trim) + .filter(|token| !token.is_empty()) + else { + continue; + }; + // Only accept the method token if it looks like a bare identifier + // (get/post/put/patch/delete), not `path = "..."` appearing first + // in an unusual ordering. + if !method.chars().all(|c| c.is_ascii_alphabetic()) { + continue; + } + let path_attr = extract_quoted_value_after(args, "path"); + // Confirm the next non-attribute, non-blank line is `pub async fn` + // per Vespera's handler requirement; otherwise this `route(...)` is + // not a real handler attribute (e.g. inside a doc example string). + let after = &source[search_from..]; + let next_code = after.lines().map(str::trim).find(|line| { + !line.is_empty() + && !line.starts_with('#') + && !line.starts_with("///") + // Skip the attribute macro's own closing bracket(s), e.g. a + // lone `]` left on its own line after `route(...)`'s `)`. + && !line.chars().all(|c| matches!(c, ']' | ')' | ',')) + }); + if next_code.is_some_and(|line| line.starts_with("pub async fn")) { + results.push((method.to_owned(), path_attr)); + } + } + results +} + +/// Finds ` = ""` inside `source` and returns ``. +fn extract_quoted_value_after(source: &str, key: &str) -> Option { + let index = source.find(key)?; + let rest = &source[index + key.len()..]; + let equals = rest.find('=')?; + let rest = &rest[equals + 1..]; + let first_quote = rest.find('"')?; + let rest = &rest[first_quote + 1..]; + let second_quote = rest.find('"')?; + Some(rest[..second_quote].to_owned()) +} + +/// Vespera's file-structure-to-URL convention: `users.rs` -> `/users`, +/// `mod.rs` (at any nesting) -> the directory path itself, `admin/stats.rs` +/// -> `/admin/stats`. Root `mod.rs` maps to the empty prefix. +fn route_url_prefix(relative_path: &Path) -> String { + let mut components = relative_path + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>(); + if let Some(last) = components.last_mut() { + if last == "mod.rs" { + components.pop(); + } else if let Some(stripped) = last.strip_suffix(".rs") { + *last = stripped.to_owned(); + } + } + if components.is_empty() { + String::new() + } else { + format!("/{}", components.join("/")) + } +} + +fn join_route_url(prefix: &str, path_attr: Option<&str>) -> String { + match path_attr { + Some(path) if !path.is_empty() => format!("{prefix}{path}"), + _ if prefix.is_empty() => "/".to_owned(), + _ => prefix.to_owned(), + } +} + +fn extract_openapi_path_methods(spec: &Value) -> Vec<(String, String)> { + const METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"]; + let mut results = Vec::new(); + if let Some(paths) = spec.get("paths").and_then(Value::as_object) { + for (path, methods) in paths { + let Some(methods) = methods.as_object() else { + continue; + }; + for method in METHODS { + if methods.contains_key(*method) { + results.push((method.to_ascii_uppercase(), path.clone())); + } + } + } + } + results +} + +// --------------------------------------------------------------------- +// openapi-client: does the frontend call endpoints the spec has? +// --------------------------------------------------------------------- + +/// Scans `.ts`/`.tsx` files (skipping generated `df/` client output and +/// the usual dependency directories) for `@devup-api/fetch`-style calls — +/// `api.get('operationIdOrPath', ...)`, `queryClient.useQuery('get', +/// 'operationIdOrPath', ...)`, `useMutation('post', 'operationIdOrPath', +/// ...)` — and checks whether each referenced identifier exists as an +/// `operationId` or raw path template in any discovered `openapi.json`. +/// String-literal extraction is done by scanning for the call-site +/// substrings and reading the following quoted literal, not a TS parser, +/// so template-built identifiers, re-exported wrapper functions, and +/// destructured/aliased `api` bindings will not be detected — +/// `confidence: "low"`. +fn openapi_client_layer(root: &Path) -> Value { + let ts_files = find_frontend_sources(root, 6); + let openapi_files = find_files_named(root, "openapi.json", 4); + if ts_files.is_empty() { + return json!({ + "checked": false, + "reason": "프론트엔드 .ts/.tsx 파일을 찾지 못했습니다.", + "drifts": [], + }); + } + if openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "openapi.json을 찾지 못해 프론트엔드 호출을 검증할 수 없습니다.", + "drifts": [], + }); + } + + let mut known_identifiers = BTreeSet::::new(); + for file in &openapi_files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + let Ok(spec) = serde_json::from_str::(&source) else { + continue; + }; + if let Some(paths) = spec.get("paths").and_then(Value::as_object) { + for (path, methods) in paths { + known_identifiers.insert(path.clone()); + if let Some(methods) = methods.as_object() { + for operation in methods.values() { + if let Some(operation_id) = + operation.get("operationId").and_then(Value::as_str) + { + known_identifiers.insert(operation_id.to_owned()); + } + } + } + } + } + } + + let mut drifts = Vec::new(); + let mut calls_checked = 0usize; + for file in &ts_files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + for (call_site, identifier) in extract_devup_api_calls(&source) { + calls_checked += 1; + if !known_identifiers.contains(&identifier) { + drifts.push(json!({ + "kind": "client-call-not-in-openapi", + "file": relative_or_absolute(root, file), + "callSite": call_site, + "identifier": identifier, + "message": "프론트엔드가 호출하는 엔드포인트/operationId를 openapi.json에서 찾지 못했습니다.", + "confidence": "low", + })); + } + } + } + + json!({ + "checked": true, + "filesScanned": ts_files.len(), + "callsChecked": calls_checked, + "knownIdentifierCount": known_identifiers.len(), + "drifts": drifts, + }) +} + +fn relative_or_absolute(root: &Path, file: &Path) -> String { + file.strip_prefix(root) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| display_path(file)) +} + +fn find_frontend_sources(root: &Path, max_depth: usize) -> Vec { + const SKIP: &[&str] = &[ + "node_modules", + "dist", + "build", + ".git", + ".next", + ".turbo", + "df", + "target", + ]; + let mut found = Vec::new(); + let mut queue = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() { + let is_ts = matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("ts") | Some("tsx") + ); + if is_ts && !name.ends_with(".d.ts") { + found.push(path); + } + } else if file_type.is_dir() && depth < max_depth && !SKIP.contains(&name.as_ref()) { + queue.push((path, depth + 1)); + } + } + } + found.sort(); + found +} + +const DEVUP_API_CALL_SITES: &[&str] = &[ + "api.get(", + "api.post(", + "api.put(", + "api.patch(", + "api.delete(", +]; +const DEVUP_API_HOOK_SITES: &[&str] = &[ + "useQuery(", + "useMutation(", + "useSuspenseQuery(", + "useInfiniteQuery(", +]; + +/// Returns `(call_site_label, referenced_identifier)` pairs found in +/// `source`. +fn extract_devup_api_calls(source: &str) -> Vec<(String, String)> { + let mut results = Vec::new(); + for call_site in DEVUP_API_CALL_SITES { + let mut search_from = 0usize; + while let Some(relative) = source[search_from..].find(call_site) { + let start = search_from + relative + call_site.len(); + if let Some(identifier) = read_next_string_literal(&source[start..]) { + results.push(((*call_site).to_owned(), identifier)); + } + search_from = start; + } + } + for call_site in DEVUP_API_HOOK_SITES { + let mut search_from = 0usize; + while let Some(relative) = source[search_from..].find(call_site) { + let start = search_from + relative + call_site.len(); + let tail = &source[start..]; + // First literal is the HTTP method ('get'/'post'/...); the + // identifier we care about is the second. + if let Some(after_method) = skip_past_string_literal(tail) + && let Some(identifier) = read_next_string_literal(after_method) + { + results.push(((*call_site).to_owned(), identifier)); + } + search_from = start; + } + } + results +} + +fn read_next_string_literal(text: &str) -> Option { + let mut chars = text.char_indices().peekable(); + let (start, quote) = loop { + let (index, character) = chars.next()?; + match character { + '\'' | '"' => break (index, character), + // Bail out if we hit something that isn't whitespace, a comma, + // or an opening paren before finding a string — this argument + // position isn't a plain string literal (e.g. a variable). + character if character.is_whitespace() || character == ',' => continue, + _ => return None, + } + }; + let rest = &text[start + 1..]; + let end = rest.find(quote)?; + Some(rest[..end].to_owned()) +} + +fn skip_past_string_literal(text: &str) -> Option<&str> { + let mut chars = text.char_indices().peekable(); + let (start, quote) = loop { + let (index, character) = chars.next()?; + match character { + '\'' | '"' => break (index, character), + character if character.is_whitespace() || character == ',' => continue, + _ => return None, + } + }; + let rest = &text[start + 1..]; + let end = rest.find(quote)?; + Some(&rest[end + 1..]) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct ScopedTempDir(PathBuf); + + impl ScopedTempDir { + fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "devup-mcp-stackdiff-test-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create scoped temp dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for ScopedTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn extracts_model_struct_fields_ignoring_derive_attributes() { + let source = r##" + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "user")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub email: String, + pub name: String, + pub avatar_url: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + "##; + let fields = extract_model_struct_fields(source); + assert_eq!( + fields, + BTreeSet::from([ + "id".to_owned(), + "email".to_owned(), + "name".to_owned(), + "avatar_url".to_owned(), + ]) + ); + } + + #[test] + fn route_url_prefix_matches_vespera_file_structure_convention() { + assert_eq!(route_url_prefix(Path::new("mod.rs")), ""); + assert_eq!(route_url_prefix(Path::new("users.rs")), "/users"); + assert_eq!(route_url_prefix(Path::new("admin/mod.rs")), "/admin"); + assert_eq!( + route_url_prefix(Path::new("admin/stats.rs")), + "/admin/stats" + ); + } + + #[test] + fn extracts_vespera_route_attributes_and_matches_path() { + let source = r##" + #[vespera::route(get, path = "/{id}", tags = ["users"])] + pub async fn get_user(Path(id): Path) -> Json { todo!() } + + #[vespera::route(post, tags = ["users"])] + pub async fn create_user() -> Json { todo!() } + "##; + let routes = extract_vespera_route_attributes(source); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].0, "get"); + assert_eq!(routes[0].1.as_deref(), Some("/{id}")); + assert_eq!(routes[1].0, "post"); + assert_eq!(routes[1].1, None); + } + + #[test] + fn extracts_devup_api_client_calls() { + let source = r##" + const user = await api.get('getUser', { params: { id: '1' } }) + await api.put('/users/{id}', { params: { id: '1' } }) + queryClient.useQuery('get', '/users/{id}', { params: { id: userId } }) + "##; + let calls = extract_devup_api_calls(source); + let identifiers = calls.iter().map(|(_, id)| id.as_str()).collect::>(); + assert!(identifiers.contains(&"getUser")); + assert!(identifiers.contains(&"/users/{id}")); + } + + #[tokio::test] + async fn db_entity_layer_flags_missing_entity_field() { + let temp = ScopedTempDir::new("db-entity"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let api_root = temp.path().join("apis").join("api"); + let models_dir = api_root.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("user.json"), + r##"{ "name": "user", "columns": [ + { "name": "id", "type": "uuid", "nullable": false }, + { "name": "phone_number", "type": "text", "nullable": true } + ] }"##, + ) + .unwrap(); + let entity_dir = api_root.join("src").join("models"); + std::fs::create_dir_all(&entity_dir).unwrap(); + std::fs::write( + entity_dir.join("user.rs"), + r##" + pub struct Model { + pub id: Uuid, + } + "##, + ) + .unwrap(); + + let result = run( + Some(&temp.path().to_string_lossy()), + &["db-entity".to_owned()], + ) + .await + .unwrap(); + let drifts = result["layers"]["db-entity"]["drifts"].as_array().unwrap(); + assert!(!drifts.is_empty()); + let drift = &drifts[0]; + assert_eq!(drift["columnsMissingInEntity"][0], "phone_number"); + } + + #[tokio::test] + async fn route_openapi_layer_flags_route_missing_from_spec() { + let temp = ScopedTempDir::new("route-openapi"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let api_root = temp.path().join("apis").join("api"); + let routes_dir = api_root.join("src").join("routes"); + std::fs::create_dir_all(&routes_dir).unwrap(); + std::fs::write( + routes_dir.join("users.rs"), + r##" + #[vespera::route(get, path = "/{id}", tags = ["users"])] + pub async fn get_user() -> Json<()> { todo!() } + "##, + ) + .unwrap(); + std::fs::write(api_root.join("openapi.json"), r##"{ "paths": {} }"##).unwrap(); + + let result = run( + Some(&temp.path().to_string_lossy()), + &["route-openapi".to_owned()], + ) + .await + .unwrap(); + let layer = &result["layers"]["route-openapi"]; + assert_eq!(layer["checked"], true); + let drifts = layer["drifts"].as_array().unwrap(); + assert!( + drifts + .iter() + .any(|drift| drift["kind"] == "route-missing-from-openapi") + ); + } + + #[tokio::test] + async fn openapi_client_layer_flags_unknown_operation_id() { + let temp = ScopedTempDir::new("openapi-client"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + std::fs::write( + temp.path().join("openapi.json"), + r##"{ "paths": { "/users": { "get": { "operationId": "getUsers" } } } }"##, + ) + .unwrap(); + let front = temp.path().join("apps").join("front").join("src"); + std::fs::create_dir_all(&front).unwrap(); + std::fs::write( + front.join("page.tsx"), + r##"const users = await api.get('getUsersThatDoesNotExist')"##, + ) + .unwrap(); + + let result = run( + Some(&temp.path().to_string_lossy()), + &["openapi-client".to_owned()], + ) + .await + .unwrap(); + let layer = &result["layers"]["openapi-client"]; + assert_eq!(layer["checked"], true); + let drifts = layer["drifts"].as_array().unwrap(); + assert!( + drifts + .iter() + .any(|drift| drift["identifier"] == "getUsersThatDoesNotExist") + ); + } + + #[tokio::test] + async fn missing_project_root_reports_guardrail() { + let temp = ScopedTempDir::new("stackdiff-no-root"); + let nested = temp.path().join("deep"); + std::fs::create_dir_all(&nested).unwrap(); + let result = run(Some(&nested.to_string_lossy()), &[]).await.unwrap(); + assert_eq!(result["found"], false); + assert_eq!(result["guardrail"]["action"], "stop-and-report"); + } + + #[tokio::test] + async fn invalid_layer_name_is_rejected() { + let temp = ScopedTempDir::new("stackdiff-bad-layer"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let error = run( + Some(&temp.path().to_string_lossy()), + &["bogus-layer".to_owned()], + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + } +} diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index e7735d8..c9df9b8 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -160,6 +160,49 @@ pub struct FigmaExploreInput { pub refresh: bool, } +/// `scope` is `theme` (project `devup.json` tokens), `api` (project +/// `openapi.json` endpoints/schemas), `db` (Vespertide `models/*.json` +/// tables/columns), or `all`. Reads whichever target file(s) actually +/// exist on disk at call time — never cached across calls, never inferred +/// when missing. See `server::project_context`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProjectContextInput { + pub scope: String, + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub filter: Option, +} + +/// Validates devup-ui TSX against the rules in `server::project_context`'s +/// sibling module `ui_validate` (crate `devup-mcp-devup-ui`): unknown +/// `$token` references, hardcoded colors/lengths with an existing token, +/// unknown props on known primitives, and non-literal values inside +/// `css`/`globalCss`/`keyframes` calls. `strict: true` additionally fails +/// `ok` on warning-severity violations. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UiValidateInput { + pub tsx: String, + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub strict: bool, +} + +/// `layers` selects which cross-layer drift checks to run +/// (`db-entity`, `entity-route`, `route-openapi`, `openapi-client`); +/// omitted or empty runs all four. See `server::stack_diff`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StackDiffInput { + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub layers: Vec, +} + fn default_scope() -> String { "node".to_owned() } diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json new file mode 100644 index 0000000..d18d55e --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/devup.json @@ -0,0 +1,25 @@ +{ + "theme": { + "colors": { + "default": { + "captionLight": "#8a8a8a", + "backgroundLight": "#fafafa", + "primaryColor": "#3366ff" + }, + "dark": { + "captionLight": "#cccccc", + "backgroundLight": "#111111", + "primaryColor": "#6699ff" + } + }, + "typography": { + "body1": { "fontSize": "14px", "lineHeight": "20px" } + }, + "length": { + "default": { "sm": "8px", "md": "16px", "lg": "24px" } + }, + "shadow": { + "default": { "card": "0 1px 2px rgba(0,0,0,0.1)" } + } + } +} diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json new file mode 100644 index 0000000..7c3eedd --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/models/message.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "message", + "columns": [ + { "name": "id", "type": "uuid", "nullable": false, "primary_key": true }, + { "name": "body", "type": "text", "nullable": false }, + { "name": "author_id", "type": "integer", "nullable": false, "foreign_key": "user.id", "index": true }, + { + "name": "status", + "type": { "kind": "enum", "name": "message_status", "values": ["draft", "sent", "deleted"] }, + "nullable": false, + "default": "'draft'" + }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ] +} diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json new file mode 100644 index 0000000..cb45e49 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/openapi.json @@ -0,0 +1,28 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Fixture API", "version": "1.0.0" }, + "paths": { + "/messages": { + "get": { "operationId": "listMessages" }, + "post": { "operationId": "createMessage" } + }, + "/messages/{id}": { + "get": { "operationId": "getMessage" }, + "delete": { "operationId": "deleteMessage" } + } + }, + "components": { + "schemas": { + "Message": { + "type": "object", + "required": ["id", "body", "authorId"], + "properties": { + "id": { "type": "string" }, + "body": { "type": "string" }, + "authorId": { "type": "string" }, + "createdAt": { "type": "string" } + } + } + } + } +} diff --git a/crates/devup-mcp/tests/fixtures/ground-truth-project/package.json b/crates/devup-mcp/tests/fixtures/ground-truth-project/package.json new file mode 100644 index 0000000..8385e10 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/ground-truth-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "ground-truth-fixture-project", + "private": true +} diff --git a/crates/devup-mcp/tests/ground_truth_tools.rs b/crates/devup-mcp/tests/ground_truth_tools.rs new file mode 100644 index 0000000..6beaf30 --- /dev/null +++ b/crates/devup-mcp/tests/ground_truth_tools.rs @@ -0,0 +1,493 @@ +//! Integration tests for the three ground-truth tools +//! (`devup_project_context`, `devup_ui_validate`, `devup_stack_diff`) added +//! to prevent the exact failure documented in `README.md`'s brief: three +//! agents independently inventing a `$gray100` color token, a 16px bubble +//! radius, and a 36px avatar size that did not exist in the project's real +//! `devup.json`. +//! +//! These tools never call Figma, so the auth/upstream mocks here are +//! trivial stubs (unlike `source_orchestration.rs`'s fixtures, which +//! simulate real collection flows) — they exist only because `DevupServer` +//! requires a `Services` value to construct. + +use std::sync::Arc; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{AuthStatus, DevupError, FigmaUpstream, ReadToolCall, UpstreamResult}; +use rmcp::{ + ServiceExt, + model::{CallToolRequestParams, CallToolResult}, +}; +use serde_json::{Map, Value, json}; + +struct NeverCalledAuth; + +#[async_trait] +impl DevupAuth for NeverCalledAuth { + async fn status(&self) -> Result { + unreachable!("ground-truth tools never touch Figma auth") + } + + async fn login(&self) -> Result { + unreachable!("ground-truth tools never touch Figma auth") + } + + async fn logout(&self) -> Result { + unreachable!("ground-truth tools never touch Figma auth") + } +} + +struct NeverCalledUpstream; + +#[async_trait] +impl FigmaUpstream for NeverCalledUpstream { + async fn list_tools(&self) -> Result, DevupError> { + unreachable!("ground-truth tools never touch Figma upstream") + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + unreachable!("ground-truth tools never touch Figma upstream") + } +} + +async fn call_tool(tool: &str, arguments: Value) -> anyhow::Result { + let server = DevupServer::new(Services::new( + Arc::new(NeverCalledAuth), + Arc::new(NeverCalledUpstream), + )); + let (server_transport, client_transport) = tokio::io::duplex(64 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = arguments.as_object().cloned().unwrap_or_default(); + let result = client + .call_tool(CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments)) + .await?; + client.cancel().await?; + task.await??; + Ok(result) +} + +/// Absolute path to `tests/fixtures/ground-truth-project`, a minimal +/// synthetic project (not real girok-space data, per the brief's "저장소에 +/// 남기는 건 최소한의 합성 데이터로 하라") with a real `devup.json`, +/// `openapi.json`, and a Vespertide `models/message.json`. +fn fixture_project_root() -> String { + format!( + "{}/tests/fixtures/ground-truth-project", + env!("CARGO_MANIFEST_DIR") + ) +} + +// --------------------------------------------------------------------- +// devup_project_context +// --------------------------------------------------------------------- + +#[tokio::test] +async fn project_context_theme_scope_reads_exact_tokens_from_the_fixture_devup_json() +-> anyhow::Result<()> { + let result = call_tool( + "devup_project_context", + json!({ "scope": "theme", "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + let file = &output["files"][0]; + assert_eq!( + file["colors"]["default"]["captionLight"], "#8a8a8a", + "must report the real fixture value, not an invented one: {output}" + ); + assert_eq!(file["colors"]["default"]["primaryColor"], "#3366ff"); + assert_eq!(file["length"]["default"]["md"], "16px"); + // The exact fabricated token from the brief's incident must NOT exist + // in this fixture's real devup.json. + assert!(file["colors"]["default"].get("gray100").is_none()); + assert!(file["colors"]["dark"].get("gray100").is_none()); + Ok(()) +} + +#[tokio::test] +async fn project_context_returns_stop_and_report_guardrail_when_devup_json_is_absent() +-> anyhow::Result<()> { + let empty_root = std::env::temp_dir().join(format!( + "devup-mcp-ground-truth-no-devup-json-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&empty_root)?; + std::fs::write(empty_root.join("package.json"), "{}")?; + + let result = call_tool( + "devup_project_context", + json!({ "scope": "theme", "projectRoot": empty_root.to_string_lossy() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], false); + assert_eq!(output["guardrail"]["action"], "stop-and-report"); + assert!( + output["guardrail"]["message"] + .as_str() + .unwrap() + .contains("추측") + ); + assert!( + !output["guardrail"]["searchedPaths"] + .as_array() + .unwrap() + .is_empty() + ); + + std::fs::remove_dir_all(&empty_root)?; + Ok(()) +} + +#[tokio::test] +async fn project_context_missing_project_root_also_reports_stop_and_report_guardrail() +-> anyhow::Result<()> { + let orphan = std::env::temp_dir().join(format!( + "devup-mcp-ground-truth-orphan-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + // No package.json/devup.json/Cargo.toml/.git anywhere in this leaf. + std::fs::create_dir_all(&orphan)?; + let result = call_tool( + "devup_project_context", + json!({ "scope": "theme", "projectRoot": orphan.to_string_lossy() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], false); + assert_eq!(output["guardrail"]["action"], "stop-and-report"); + std::fs::remove_dir_all(&orphan)?; + Ok(()) +} + +#[tokio::test] +async fn project_context_api_scope_lists_real_endpoints_and_required_fields() -> anyhow::Result<()> +{ + let result = call_tool( + "devup_project_context", + json!({ "scope": "api", "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + let spec = &output["specs"][0]; + let operation_ids = spec["endpoints"] + .as_array() + .unwrap() + .iter() + .filter_map(|endpoint| endpoint["operationId"].as_str()) + .collect::>(); + assert!(operation_ids.contains(&"listMessages")); + assert!(operation_ids.contains(&"getMessage")); + let message_schema = spec["schemas"] + .as_array() + .unwrap() + .iter() + .find(|schema| schema["name"] == "Message") + .expect("Message schema present"); + let required = message_schema["requiredFields"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>(); + assert!(required.contains(&"authorId")); + Ok(()) +} + +#[tokio::test] +async fn project_context_db_scope_lists_real_columns_and_enum_values() -> anyhow::Result<()> { + let result = call_tool( + "devup_project_context", + json!({ "scope": "db", "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + let table = &output["tables"][0]; + assert_eq!(table["table"], "message"); + let column_names = table["columns"] + .as_array() + .unwrap() + .iter() + .map(|column| column["name"].as_str().unwrap()) + .collect::>(); + assert!(column_names.contains(&"author_id")); + assert!(column_names.contains(&"status")); + let enum_def = &table["enums"][0]; + assert_eq!(enum_def["values"][0], "draft"); + Ok(()) +} + +// --------------------------------------------------------------------- +// devup_ui_validate — the $gray100 regression case is the core deliverable +// --------------------------------------------------------------------- + +#[tokio::test] +async fn ui_validate_catches_the_exact_gray100_regression_from_the_incident() -> anyhow::Result<()> +{ + // This TSX is exactly the shape of the fabricated failure documented + // in the brief: an agent using a plausible-looking but nonexistent + // color token instead of one of the real tokens in devup.json. + let tsx = r##" + import { Box } from "@devup-ui/react"; + + export const ChatBubble = () => ( + + ); + "##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["themeAvailable"], true); + assert_eq!(output["ok"], false, "must fail: {output}"); + let violations = output["violations"].as_array().unwrap(); + let token_violation = violations + .iter() + .find(|violation| violation["rule"] == "unknown-token") + .expect("unknown-token violation for $gray100"); + assert_eq!(token_violation["severity"], "error"); + assert!( + token_violation["message"] + .as_str() + .unwrap() + .contains("gray100"), + "{token_violation}" + ); + // The tool must not silently accept the same input's hardcoded 16px + // radius either — devup.json has a real "md": "16px" length token. + assert!( + violations + .iter() + .any(|violation| violation["rule"] == "hardcoded-length"), + "{violations:?}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_accepts_tsx_using_only_real_project_tokens() -> anyhow::Result<()> { + let tsx = r##" + import { Box } from "@devup-ui/react"; + + export const ChatBubble = () => ( + + ); + "##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["ok"], true, "{output}"); + assert_eq!(output["checkedTokens"], 2); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_suggests_the_matching_real_token_for_a_hardcoded_hex_color() +-> anyhow::Result<()> { + let tsx = r##""##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + let violation = output["violations"] + .as_array() + .unwrap() + .iter() + .find(|violation| violation["rule"] == "hardcoded-color") + .expect("hardcoded-color violation"); + assert_eq!(violation["severity"], "warning"); + assert!( + violation["suggestion"] + .as_str() + .unwrap() + .contains("captionLight"), + "{violation}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_suggests_the_matching_real_token_for_a_hardcoded_px_length() +-> anyhow::Result<()> { + let tsx = r##""##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + let violation = output["violations"] + .as_array() + .unwrap() + .iter() + .find(|violation| violation["rule"] == "hardcoded-length") + .expect("hardcoded-length violation"); + assert!(violation["suggestion"].as_str().unwrap().contains("md")); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_catches_runtime_value_inside_css_call() -> anyhow::Result<()> { + let tsx = r##" + import { css } from "@devup-ui/react"; + const dynamicWidth = getWidth(); + const cls = css({ width: dynamicWidth }); + "##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["ok"], false); + assert!( + output["violations"] + .as_array() + .unwrap() + .iter() + .any(|violation| violation["rule"] == "runtime-value"), + "{output}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_does_not_flag_dynamic_jsx_props_as_runtime_value() -> anyhow::Result<()> { + // Verified against @devup-ui/react's own docs: `` + // compiles to a CSS custom property, it is not a runtime-value error. + let tsx = r##"export const X = ({ color }) => ;"##; + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert!( + output["violations"] + .as_array() + .unwrap() + .iter() + .all(|violation| violation["rule"] != "runtime-value"), + "{output}" + ); + Ok(()) +} + +#[tokio::test] +async fn ui_validate_reports_missing_theme_without_crashing_and_skips_token_checks() +-> anyhow::Result<()> { + let empty_root = std::env::temp_dir().join(format!( + "devup-mcp-ground-truth-ui-validate-no-theme-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&empty_root)?; + std::fs::write(empty_root.join("package.json"), "{}")?; + + let result = call_tool( + "devup_ui_validate", + json!({ "tsx": r##""##, "projectRoot": empty_root.to_string_lossy() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["themeAvailable"], false); + assert_eq!(output["themeGuardrail"]["action"], "stop-and-report"); + assert!( + output["violations"] + .as_array() + .unwrap() + .iter() + .all(|violation| violation["rule"] != "unknown-token"), + "without a theme, unknown-token must be skipped, not guessed at: {output}" + ); + + std::fs::remove_dir_all(&empty_root)?; + Ok(()) +} + +#[tokio::test] +async fn ui_validate_strict_mode_fails_on_warning_severity_violations() -> anyhow::Result<()> { + let tsx = r##""##; + let lenient = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root(), "strict": false }), + ) + .await? + .structured_content + .unwrap(); + let strict = call_tool( + "devup_ui_validate", + json!({ "tsx": tsx, "projectRoot": fixture_project_root(), "strict": true }), + ) + .await? + .structured_content + .unwrap(); + assert_eq!(lenient["ok"], true); + assert_eq!(strict["ok"], false); + Ok(()) +} + +// --------------------------------------------------------------------- +// devup_stack_diff +// --------------------------------------------------------------------- + +#[tokio::test] +async fn stack_diff_reports_every_requested_layer_with_explicit_confidence() -> anyhow::Result<()> { + let result = call_tool( + "devup_stack_diff", + json!({ "projectRoot": fixture_project_root() }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["found"], true); + for layer in [ + "db-entity", + "entity-route", + "route-openapi", + "openapi-client", + ] { + assert!( + output["layers"].get(layer).is_some(), + "missing layer {layer} in {output}" + ); + assert!( + output["layers"][layer].get("checked").is_some(), + "layer {layer} must report whether it could run" + ); + } + Ok(()) +} + +#[tokio::test] +async fn stack_diff_rejects_unknown_layer_names() -> anyhow::Result<()> { + let result = call_tool( + "devup_stack_diff", + json!({ "projectRoot": fixture_project_root(), "layers": ["not-a-real-layer"] }), + ) + .await; + assert!(result.is_err()); + Ok(()) +} diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index 28f51da..b590b8d 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -209,8 +209,8 @@ fn tools_list_over_raw_stdio_has_no_boolean_schemas_and_object_output_types() -> .expect("tools/list result must contain a tools array"); assert_eq!( tools.len(), - 7, - "expected all 7 devup_figma_* tools to be listed: {tools:?}" + 10, + "expected all 10 devup-mcp tools (7 devup_figma_* + devup_project_context + devup_ui_validate + devup_stack_diff) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index 4e051d6..95c6cc9 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -73,6 +73,13 @@ fn collect_boolean_schemas(path: &str, node: &Value, hits: &mut Vec) { } } +// NOTE: kept as `exposes_the_seven_read_only_devup_figma_tools` even though +// this now asserts 10 tools (7 devup_figma_* + 3 ground-truth tools): +// `fixtures/devup-figma-plugin/{ledger,coverage-registry}.json` reference +// this exact Rust test symbol as coverage evidence for the pinned plugin +// compatibility corpus, and the brief instructs not to touch the Figma +// pipeline. Renaming this function would require rewriting ~40 fixture +// entries in a file this task must not modify. #[tokio::test] async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); @@ -150,6 +157,9 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { "devup_figma_search", "devup_figma_to_json", "devup_figma_to_ui", + "devup_project_context", + "devup_stack_diff", + "devup_ui_validate", ] ); From d7c58a13a9e39993769dd5c221399ce1d20969d5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 2 Sep 2026 22:10:16 +0900 Subject: [PATCH 02/69] fix: guarantee handoff completion and ban hand-interpreting the node tree Real observed failure (2026-09-02, girok-space WQUW-156/WQUW-147): devup_figma_continue rejected an agent-submitted result because opencode's host handoff flattens the official Figma MCP CallToolResult down to plain text before the agent ever sees it - the agent has no envelope to 'pass through unchanged', only a bare string, so it fabricated a plausible {'content':[{'type':'text','text':...}]} wrapper by hand. When the handoff never completed, the agent fell back to hand-interpreting use_figma's raw node tree (coordinates/sizes) to write devup-ui code by hand instead - exactly the fabrication devup-mcp exists to prevent, and it broke the UI. 1. HandoffStore::accept() now normalizes the incoming result before it reaches the collector: a bare string is promoted to the minimal MCP content-block envelope; a content-array-without-structuredContent result is passed through unchanged as long as it has at least one usable item (every real extraction path already tolerates this shape by design - XML-text metadata, JSON-in-text snapshots, image content for screenshots). Shape promotion only, never data invention. 2. The one case genuinely rejected - content with nothing usable and no structuredContent either - now returns DEVUP_FIGMA_HANDOFF_INVALID / missing_structured_content with the expected schema, the received shape (key names and content-block types only, never values), and explicit howToFix/doNot guidance, instead of a generic 'metadata not found' the agent had to guess its way around. 3-4. hostRequirement now carries resultContract (submit the official response unprocessed; if the host flattens to text, wrap only in {content:[{type:text,text:}]}; never fabricate structuredContent) and outputExpectation (devup-mcp will hand back devup-ui TSX; never hand-interpret use_figma's node tree to write layout code; stop-and-report if conversion fails) on every needs_figma step - the core deliverable of this fix. 5. devup_figma_to_ui and devup_figma_export now attach an unambiguous deliverable: {kind: 'devup-ui-tsx', isFinal: true, note} whenever a tsx was actually produced and status is complete, so an agent that has only seen needs_figma steps can no longer mistake an intermediate step for the final answer. No changes to Figma collection logic, codegen, or the write-root policy - this is entirely the handoff contract and host-facing guidance. 17 new/extended tests across handoff.rs (normalization + rejection shape/no-leak guarantees), figma_doctor.rs (resultContract/ outputExpectation present on every needs_figma), source_orchestration.rs and composite_export.rs (deliverable marker on true completion, absent otherwise). All pre-existing regression tests (boolean-schema-free schemas, hostRequirement stop-and-report, stringified-result handling) still pass unchanged. --- crates/devup-mcp/src/server/diagnostics.rs | 56 ++++++ crates/devup-mcp/src/server/handoff.rs | 113 +++++++++++ crates/devup-mcp/src/server/projection.rs | 34 +++- crates/devup-mcp/tests/composite_export.rs | 9 + crates/devup-mcp/tests/figma_doctor.rs | 70 +++++++ crates/devup-mcp/tests/handoff.rs | 179 ++++++++++++++++++ .../devup-mcp/tests/source_orchestration.rs | 106 +++++++++++ 7 files changed, 566 insertions(+), 1 deletion(-) diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index a5c6b4b..26e51a4 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -103,6 +103,16 @@ pub async fn host_requirement() -> Value { "action": "stop-and-report", "message": "Figma MCP에 접근할 수 없으면 즉시 멈추고 보고하세요. 디자인 수치를 추측해서 구현하지 마세요.", "setupHint": "devup_figma_auth { action: \"doctor\" } 를 호출하면 사용 가능한 경로와 클라이언트별 설정 방법을 얻을 수 있습니다." + }, + "resultContract": { + "expects": "공식 Figma MCP CallToolResult 원본 전체 (가공 금지)", + "ifHostFlattensToText": "호스트가 텍스트만 준다면 { \"content\": [{ \"type\": \"text\", \"text\": <원문 그대로> }] } 로만 감싸라.", + "neverFabricate": "structuredContent 등 없는 필드를 지어내지 마라. 두 번 이상 형식 오류가 나면 추측을 멈추고 보고하라." + }, + "outputExpectation": { + "whatYouWillGet": "이 핸드오프가 완주하면 devup-mcp가 devup-ui TSX를 생성해 반환한다.", + "doNotHandInterpret": "use_figma가 반환한 노드 트리(좌표·크기·계층)를 직접 해석해서 devup-ui 코드를 작성하지 마라. 좌표 계산으로 레이아웃을 추론하지 마라. 그것이 devup-mcp가 존재하는 이유다.", + "ifConversionFails": "stop-and-report. 노드 트리를 근거로 UI를 손으로 작성하는 것은 금지된 폴백이다." } }) } @@ -215,6 +225,52 @@ mod tests { assert!(value["localDevMode"]["reachable"].is_boolean()); } + #[tokio::test] + async fn host_requirement_always_carries_result_contract_and_output_expectation() { + let value = host_requirement().await; + + // resultContract: tells the agent what to submit to + // devup_figma_continue, and explicitly forbids inventing envelope + // fields when the host only exposes flattened text. + assert!( + !value["resultContract"]["expects"] + .as_str() + .unwrap() + .is_empty() + ); + assert!( + value["resultContract"]["ifHostFlattensToText"] + .as_str() + .unwrap() + .contains("content") + ); + assert!( + value["resultContract"]["neverFabricate"] + .as_str() + .unwrap() + .contains("structuredContent") + ); + + // outputExpectation: the core deliverable of this task — bans + // hand-interpreting the node tree as a fallback when conversion + // stalls. + assert!( + value["outputExpectation"]["whatYouWillGet"] + .as_str() + .unwrap() + .contains("devup-ui") + ); + let do_not_hand_interpret = value["outputExpectation"]["doNotHandInterpret"] + .as_str() + .unwrap(); + assert!(do_not_hand_interpret.contains("노드 트리")); + assert!(do_not_hand_interpret.contains("devup-ui")); + assert_eq!( + value["outputExpectation"]["ifConversionFails"], + "stop-and-report. 노드 트리를 근거로 UI를 손으로 작성하는 것은 금지된 폴백이다." + ); + } + #[tokio::test] async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { let connected = doctor_report(AuthStatus::Connected).await; diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index 1cc8b95..9d111e6 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -263,6 +263,7 @@ impl HandoffStore { call_id: &str, result: Value, ) -> Result<(), DevupError> { + let result = normalize_handoff_result(result)?; let encoded_len = serde_json::to_vec(&result) .map_err(|_| invalid("Figma handoff result를 JSON으로 읽을 수 없습니다."))? .len(); @@ -453,3 +454,115 @@ fn too_large(message: &str) -> DevupError { json!({"source": "host"}), ) } + +/// Normalizes a `devup_figma_continue` `result` payload before it reaches +/// the collector. This is the fix for a real observed failure: opencode's +/// host handoff flattens an official Figma MCP `CallToolResult` down to +/// plain text before the agent ever sees it, so the agent has no envelope +/// to "pass through unchanged" — only a bare string. An agent that has +/// nothing but that string has previously invented a plausible-looking +/// `{"content":[{"type":"text","text":...}]}` wrapper by hand rather than +/// submit the string directly, which is exactly the kind of fabrication +/// this module exists to make unnecessary. +/// +/// Two things happen here, and nothing else: +/// +/// - A bare [`Value::String`] is promoted to the minimal MCP content-block +/// envelope `{"content": [{"type": "text", "text": }]}`. This is +/// shape promotion only — the string itself is carried through +/// byte-for-byte, never modified, parsed, or re-interpreted. +/// - An object that has a `content` array but no `structuredContent` is +/// passed through unchanged *as long as at least one content item is +/// actually usable* (non-empty text, or image data). Every extraction +/// path in this codebase's collector already tolerates content-only +/// envelopes by design (`get_metadata`'s XML-text fallback, +/// variable/snapshot JSON encoded as `content[].text`, image content for +/// screenshots, ...), so rejecting these here would be a regression, not +/// a fix. +/// +/// The only case rejected outright: a `content` array with nothing usable +/// in it and no `structuredContent` either. That shape gives every +/// downstream extraction path nothing to work with regardless of which +/// Figma tool the call was for, so failing fast here — with a +/// schema-shaped, non-design-leaking error — is strictly better than +/// letting the agent discover that after the collector's own, more +/// generic rejection. +/// +/// Never fabricates data: this function only ever promotes or rejects +/// based on *shape*. It never invents a `structuredContent` value or edits +/// the content the caller actually sent. +fn normalize_handoff_result(result: Value) -> Result { + let promoted = match result { + Value::String(text) => json!({ "content": [{ "type": "text", "text": text }] }), + other => other, + }; + if let Value::Object(object) = &promoted + && let Some(Value::Array(content)) = object.get("content") + && !object.contains_key("structuredContent") + && !content.iter().any(has_usable_content_item) + { + return Err(missing_structured_content_error(&promoted)); + } + Ok(promoted) +} + +/// A content block counts as usable if it carries non-empty text, or +/// non-empty image data — the two shapes this codebase's collector +/// actually extracts from `content[]` today. +fn has_usable_content_item(item: &Value) -> bool { + let has_text = item + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| !text.trim().is_empty()); + let has_image_data = item.get("type").and_then(Value::as_str) == Some("image") + && item + .get("data") + .and_then(Value::as_str) + .is_some_and(|data| !data.is_empty()); + has_text || has_image_data +} + +/// Builds the `DEVUP_FIGMA_HANDOFF_INVALID` / `missing_structured_content` +/// rejection: the shape devup-mcp actually expects, the shape it received +/// (key names and content block `type`s only — see [`received_shape`]), +/// and explicit next-step guidance that forbids guessing the envelope. +fn missing_structured_content_error(value: &Value) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "Figma handoff 결과에서 사용할 수 있는 content나 structuredContent를 찾지 못했습니다.", + false, + json!({ + "reason": "missing_structured_content", + "expectedSchema": { + "content": [{ "type": "text", "text": "" }], + "structuredContent": { "devupMetadata": "" } + }, + "receivedShape": received_shape(value), + "howToFix": "공식 Figma MCP 응답을 가공하지 말고 원본 그대로 넘겨라. 호스트가 텍스트만 노출한다면 sourcePolicy 또는 수집 경로를 바꿔야 한다.", + "doNot": "봉투 필드를 추측해서 만들어 넣지 마라." + }), + ) +} + +/// Only key names and content-block `type` strings — never a value that +/// could carry design text, tokens, or credentials. This is deliberate: +/// the whole point of this error is to tell the agent what shape it sent +/// without ever echoing anything from the design or the upstream response +/// back into an error message. +fn received_shape(value: &Value) -> Value { + let top_level_keys = match value { + Value::Object(object) => object.keys().cloned().collect::>(), + _ => Vec::new(), + }; + let mut content_types = value + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("type").and_then(Value::as_str)) + .map(str::to_owned) + .collect::>(); + content_types.sort(); + content_types.dedup(); + json!({ "topLevelKeys": top_level_keys, "contentTypes": content_types }) +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 50c7330..6cfc0ab 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -459,6 +459,19 @@ pub(super) async fn complete_operation( }; commit_delivery(attachment); result["outputPath"] = json!(written_path); + if status == "complete" { + // Unambiguous "this is the real, final answer" marker. + // Without it, an agent repeatedly seeing `needs_figma` + // intermediate steps has, in an observed real failure, + // concluded the conversion was "probably done" and moved + // on to hand-interpreting the raw node tree instead of + // waiting for this response. + result["deliverable"] = json!({ + "kind": "devup-ui-tsx", + "isFinal": true, + "note": "이 tsx가 최종 산출물이다. 이 값을 근거로 구현하라." + }); + } Ok(result) } PendingOperation::ToJson { @@ -1035,8 +1048,27 @@ pub(super) async fn complete_operation( }), )); } - result.insert("status".to_owned(), json!(quality.status())); + let final_status = quality.status(); + result.insert("status".to_owned(), json!(final_status)); result.insert("quality".to_owned(), json!(quality)); + let tsx_produced = + section_tsx_projected || outputs.iter().any(|output| output == "tsx"); + if final_status == "complete" && tsx_produced { + // Same unambiguous final-answer marker as devup_figma_to_ui + // — see that branch's comment for why this exists. Checked + // here (before `apply_delivery` may move `tsx`/each frame's + // `tsx` into `resources`) so the marker reflects whether a + // devup-ui TSX was actually produced, independent of how + // large output routed it for delivery. + result.insert( + "deliverable".to_owned(), + json!({ + "kind": "devup-ui-tsx", + "isFinal": true, + "note": "이 tsx가 최종 산출물이다. 이 값을 근거로 구현하라." + }), + ); + } let mut planned_outputs = Vec::new(); for (output, contents) in pending_text_outputs { if let Some(path) = output_paths.get(&output) { diff --git a/crates/devup-mcp/tests/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index 2d24883..3dea9fe 100644 --- a/crates/devup-mcp/tests/composite_export.rs +++ b/crates/devup-mcp/tests/composite_export.rs @@ -136,6 +136,10 @@ async fn reference_png_is_acquired_once_and_delivered_as_a_binary_resource() -> reference_png_base64() ); assert_eq!(acquired["cache"]["capabilities"]["referencePng"], true); + // No tsx was requested/produced by this export, so no deliverable + // marker should be attached — it must not claim a devup-ui-tsx exists + // when only a reference PNG was exported. + assert!(acquired.get("deliverable").is_none()); let artifact_id = acquired["cache"]["artifactId"].as_str().unwrap(); let delivered_result = call_result( @@ -236,6 +240,11 @@ async fn one_acquisition_projects_all_outputs_and_artifact_reuse_is_zero_call() assert_eq!(first["cache"]["cacheHit"], false); assert!(first["cache"]["artifactId"].as_str().is_some()); assert!(first["tsx"].as_str().unwrap().contains("$primary")); + // devup_figma_export must carry the same unambiguous final-answer + // marker as devup_figma_to_ui when it actually produced a tsx output. + assert_eq!(first["deliverable"]["kind"], "devup-ui-tsx"); + assert_eq!(first["deliverable"]["isFinal"], true); + assert!(!first["deliverable"]["note"].as_str().unwrap().is_empty()); assert!(first["devupJson"].as_str().unwrap().contains("\"primary\"")); assert_eq!(first["rawSnapshot"]["roots"], json!(["1:2"])); assert_eq!(first["sourceMap"]["version"], 1); diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index 7b8b6c0..4f7f580 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -215,6 +215,64 @@ async fn needs_figma_always_carries_an_actionable_host_requirement() -> anyhow:: Ok(()) } +/// The core deliverable of the handoff-completion fix: every `needs_figma` +/// step must carry `hostRequirement.resultContract` (so the agent submits +/// the right shape from the start) and `hostRequirement.outputExpectation` +/// (so it never falls back to hand-interpreting `use_figma`'s raw node +/// tree while waiting for devup-mcp's own TSX). See the real incident this +/// fixes in `crates/devup-mcp/src/server/handoff.rs`'s module docs. +#[tokio::test] +async fn needs_figma_always_carries_result_contract_and_output_expectation() -> anyhow::Result<()> { + let result = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_to_ui", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "sourcePolicy": "auto" + }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output["status"], "needs_figma"); + let host_requirement = &output["hostRequirement"]; + + let result_contract = &host_requirement["resultContract"]; + assert!(!result_contract["expects"].as_str().unwrap().is_empty()); + assert!( + result_contract["ifHostFlattensToText"] + .as_str() + .unwrap() + .contains("content") + ); + assert!( + result_contract["neverFabricate"] + .as_str() + .unwrap() + .contains("structuredContent") + ); + + let output_expectation = &host_requirement["outputExpectation"]; + assert!( + output_expectation["whatYouWillGet"] + .as_str() + .unwrap() + .contains("devup-ui") + ); + let do_not_hand_interpret = output_expectation["doNotHandInterpret"].as_str().unwrap(); + assert!(do_not_hand_interpret.contains("노드 트리")); + assert!(do_not_hand_interpret.contains("devup-ui")); + assert!( + output_expectation["ifConversionFails"] + .as_str() + .unwrap() + .contains("stop-and-report") + ); + Ok(()) +} + #[tokio::test] async fn host_policy_needs_figma_also_carries_the_host_requirement() -> anyhow::Result<()> { let result = call_named_tool( @@ -236,5 +294,17 @@ async fn host_policy_needs_figma_also_carries_the_host_requirement() -> anyhow:: output["hostRequirement"]["ifUnavailable"]["action"], "stop-and-report" ); + // resultContract/outputExpectation must be present regardless of which + // sourcePolicy triggered the handoff. + assert!( + output["hostRequirement"]["resultContract"]["expects"] + .as_str() + .is_some() + ); + assert!( + output["hostRequirement"]["outputExpectation"]["doNotHandInterpret"] + .as_str() + .is_some() + ); Ok(()) } diff --git a/crates/devup-mcp/tests/handoff.rs b/crates/devup-mcp/tests/handoff.rs index 278dd9d..258805e 100644 --- a/crates/devup-mcp/tests/handoff.rs +++ b/crates/devup-mcp/tests/handoff.rs @@ -394,6 +394,185 @@ async fn rejects_cross_session_calls_and_concurrent_replays() { ); } +/// The exact incident this fix addresses: an agent whose host flattens the +/// official Figma MCP `get_metadata` response down to a bare string (no +/// envelope at all — see `handoff.rs`'s `normalize_handoff_result` doc +/// comment) submits that string directly. It must succeed without the +/// agent inventing a `{"content":[...]}"` wrapper by hand. +#[tokio::test] +async fn accept_promotes_a_bare_non_json_string_to_a_content_envelope() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "get_metadata"); + + // Bare XML text, not JSON, not wrapped — exactly what a host that + // flattens tool results to plain text would hand the agent. + let bare_xml = "".to_owned(); + store + .accept(&id, &calls[0].call_id, Value::String(bare_xml)) + .await + .unwrap(); + + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "use_figma"); +} + +/// The "그대로 통과시키되" half of the normalization contract: a `content` +/// array with usable text but no `structuredContent` must NOT be rejected. +/// Every real extraction path in this codebase's collector already +/// tolerates this shape by design (XML-text metadata, JSON-in-text +/// snapshots, ...); rejecting it here would be a regression. +#[tokio::test] +async fn accept_passes_through_content_only_result_when_text_is_usable() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + + let content_only = json!({ + "content": [{ + "type": "text", + "text": "" + }] + }); + store + .accept(&id, &calls[0].call_id, content_only) + .await + .unwrap(); +} + +/// `structuredContent` presence always exempts a result from the +/// no-usable-content rejection, regardless of what (if anything) is in +/// `content` alongside it. +#[tokio::test] +async fn accept_passes_through_structured_content_even_with_an_empty_content_array() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + + let mut with_empty_content = metadata_result(); + with_empty_content["content"] = json!([]); + store + .accept(&id, &calls[0].call_id, with_empty_content) + .await + .unwrap(); +} + +/// The one case this fix does reject: a `content` array with nothing +/// usable in it and no `structuredContent` either. Every reported field +/// must be exactly the brief's `expectedSchema`/`receivedShape` contract, +/// and `receivedShape` must never leak a value — only key names and +/// content-block `type`s. +#[tokio::test] +async fn accept_rejects_empty_content_with_a_schema_shaped_error_that_leaks_no_values() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + + let error = store + .accept(&id, &calls[0].call_id, json!({"content": []})) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); + assert_eq!(error.details["reason"], "missing_structured_content"); + assert_eq!( + error.details["expectedSchema"]["content"][0]["type"], + "text" + ); + assert!( + error.details["expectedSchema"]["structuredContent"]["devupMetadata"] + .as_str() + .unwrap() + .contains("필수") + ); + assert_eq!( + error.details["receivedShape"]["topLevelKeys"], + json!(["content"]) + ); + assert_eq!(error.details["receivedShape"]["contentTypes"], json!([])); + assert!( + !error.details["howToFix"].as_str().unwrap().is_empty(), + "must tell the agent what to do next, not just that it failed" + ); + assert!(error.details["doNot"].as_str().unwrap().contains("추측")); +} + +/// Non-empty but still unusable content (an image block with no `data`, a +/// whitespace-only text block) is rejected the same way, and the block +/// `type`s are reported — but never the (here, absent) `data`/`text` +/// values themselves. +#[tokio::test] +async fn accept_rejects_content_with_no_usable_items_reporting_types_not_values() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + + let error = store + .accept( + &id, + &calls[0].call_id, + json!({"content": [{"type": "image"}, {"type": "text", "text": " "}]}), + ) + .await + .unwrap_err(); + assert_eq!(error.details["reason"], "missing_structured_content"); + assert_eq!( + error.details["receivedShape"]["contentTypes"], + json!(["image", "text"]) + ); + // The (absent) design/binary values must never appear in the error. + let rendered = error.details.to_string(); + assert!(!rendered.contains("\"data\"")); + assert!(!rendered.contains("\"text\":\" \"")); +} + +/// An empty string, once promoted, carries no usable text — it must be +/// rejected rather than silently accepted as "successful but empty". +#[tokio::test] +async fn accept_rejects_a_bare_empty_string() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + + let error = store + .accept(&id, &calls[0].call_id, Value::String(String::new())) + .await + .unwrap_err(); + assert_eq!(error.details["reason"], "missing_structured_content"); +} + #[tokio::test] async fn enforces_the_aggregate_limit_across_sessions() { let payload = metadata_result(); diff --git a/crates/devup-mcp/tests/source_orchestration.rs b/crates/devup-mcp/tests/source_orchestration.rs index aea317f..88a761e 100644 --- a/crates/devup-mcp/tests/source_orchestration.rs +++ b/crates/devup-mcp/tests/source_orchestration.rs @@ -351,6 +351,112 @@ async fn connected_auto_completes_through_the_direct_collector() -> anyhow::Resu assert_eq!(output["collection"]["fallbackUsed"], true); assert_eq!(upstream.calls.load(Ordering::SeqCst), 3); assert_eq!(auth.logins.load(Ordering::SeqCst), 0); + + // The unambiguous final-answer marker: without it, an agent that only + // ever sees intermediate `needs_figma` steps has, in a real observed + // failure, concluded the conversion was "probably done" and started + // hand-interpreting the raw node tree instead of using this `tsx`. + assert_eq!(output["deliverable"]["kind"], "devup-ui-tsx"); + assert_eq!(output["deliverable"]["isFinal"], true); + assert!(!output["deliverable"]["note"].as_str().unwrap().is_empty()); + Ok(()) +} + +#[tokio::test] +async fn host_completion_also_carries_the_deliverable_marker() -> anyhow::Result<()> { + let auth = Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + logins: AtomicUsize::new(0), + }); + let upstream = Arc::new(UpstreamProbe::unavailable()); + let server = DevupServer::new(Services::new(auth, upstream)); + let (server_transport, client_transport) = tokio::io::duplex(128 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let start = client + .call_tool( + CallToolRequestParams::new("devup_figma_to_ui") + .with_arguments(input("host").as_object().cloned().unwrap()), + ) + .await? + .structured_content + .unwrap(); + // Neither the `needs_figma` step itself nor any of its host-requirement + // guidance is a deliverable: only the final `complete` response is. + assert_eq!(start["status"], "needs_figma"); + assert!(start.get("deliverable").is_none()); + assert!( + start["hostRequirement"]["outputExpectation"]["doNotHandInterpret"] + .as_str() + .is_some() + ); + + let session_id = start["sessionId"].as_str().unwrap(); + let fast_call = start["calls"][0]["callId"].as_str().unwrap(); + let after_fast = client + .call_tool( + CallToolRequestParams::new("devup_figma_continue").with_arguments( + json!({ + "sessionId": session_id, + "callId": fast_call, + "result": snapshot_result() + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await? + .structured_content + .unwrap(); + assert!(after_fast.get("deliverable").is_none()); + + let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); + let after_metadata = client + .call_tool( + CallToolRequestParams::new("devup_figma_continue").with_arguments( + json!({ + "sessionId": session_id, + "callId": metadata_call, + "result": metadata_result() + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await? + .structured_content + .unwrap(); + assert!(after_metadata.get("deliverable").is_none()); + + let snapshot_call = after_metadata["calls"][0]["callId"].as_str().unwrap(); + let complete = client + .call_tool( + CallToolRequestParams::new("devup_figma_continue").with_arguments( + json!({ + "sessionId": session_id, + "callId": snapshot_call, + "result": snapshot_result() + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await? + .structured_content + .unwrap(); + assert_eq!(complete["status"], "complete"); + assert_eq!(complete["deliverable"]["kind"], "devup-ui-tsx"); + assert_eq!(complete["deliverable"]["isFinal"], true); + + client.cancel().await?; + task.await??; Ok(()) } From 2e31067e7f4ccc571682281de2a9f26051b3d856 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 2 Sep 2026 23:48:34 +0900 Subject: [PATCH 03/69] fix: make Figma handoffs self-enforcing across MCP clients Real observed WQUW-156 failure: without consumer-repository instructions, opencode ran get_metadata for a callId that requested use_figma, submitted that result unchanged, received only a generic downstream metadata/snapshot error, then hand-edited envelopes and routed around devup-mcp. 1. Replace the initialize instructions with seven Korean operating rules that make devup-mcp the primary Figma-to-code source, require export-first implementation, preserve raw handoff results, and stop rather than fabricate values. 2. Clarify the five Figma tool descriptions so clients can distinguish export from TSX-only conversion, use search/explore before export, and execute continuation calls exactly as requested. 3. Compare each pending call's recorded tool with the official get_metadata reminder signature before collector dispatch. Unambiguous wrong-tool results now return DEVUP_FIGMA_HANDOFF_INVALID/tool_mismatch while leaving the call pending for a correct retry. 4. Strip Figma's fixed get_metadata reminder from every content[].text block after mismatch detection and before downstream parsing, without changing any other result data or inventing structured content. Regression coverage adds a literal WQUW-156 wrong-tool sequence, text-only XML metadata with and without the reminder, conservative get_metadata acceptance, retriable mismatch rejection, and a unit test proving truncation is limited to content text. The existing handoff, boolean-schema, doctor, deliverable, and full workspace suites remain green. --- crates/devup-mcp/src/server/handoff.rs | 103 ++++++++++++++++++++++- crates/devup-mcp/src/server/mod.rs | 20 +++-- crates/devup-mcp/tests/handoff.rs | 112 +++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 7 deletions(-) diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index 9d111e6..a69a95a 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -143,6 +143,7 @@ struct SessionTombstone { } const MAX_TOMBSTONES: usize = 64; +const GET_METADATA_RESULT_TAIL: &str = "IMPORTANT: After you call this tool, you MUST call get_design_context if trying to implement the design, since this tool only returns metadata. If you do not call get_design_context, the agent will not be able to implement the design."; #[derive(Clone)] pub struct HandoffStore { @@ -287,7 +288,7 @@ impl HandoffStore { )); } let mut session = take_session(&mut state, session_id, now, self.limits.ttl.as_secs())?; - let Some((collector_call_id, _)) = session.pending.get(call_id) else { + let Some((collector_call_id, handoff_call)) = session.pending.get(call_id) else { let reason = if session.consumed.contains(call_id) { "consumed" } else { @@ -300,6 +301,13 @@ impl HandoffStore { )); }; let collector_call_id = collector_call_id.clone(); + let requested_tool = handoff_call.tool; + if let Some(error) = detect_tool_mismatch(requested_tool, call_id, &result) { + put_session(&mut state, session_id.to_owned(), session); + return Err(error); + } + let mut result = result; + strip_get_metadata_tail(&mut result); let mut accepted_collector = session.collector.clone(); if let Err(error) = accepted_collector.accept(&collector_call_id, UpstreamResult { raw: result }) @@ -455,6 +463,66 @@ fn too_large(message: &str) -> DevupError { ) } +/// Rejects the WQUW-156 wrong-tool handoff before the collector interprets +/// a `get_metadata` response using another call's recorded kind. +/// +/// Detection is deliberately conservative: Figma's complete fixed reminder +/// is the only signature recognized today, and it is always legitimate when +/// the recorded request itself was `get_metadata`. Other result shapes are +/// left to the collector rather than guessed from design content. +fn detect_tool_mismatch(requested_tool: &str, call_id: &str, value: &Value) -> Option { + if requested_tool == "get_metadata" || !contains_get_metadata_tail(value) { + return None; + } + Some(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "요청한 도구가 아닌 다른 Figma 도구의 결과로 보입니다.", + false, + json!({ + "reason": "tool_mismatch", + "requested": { "tool": requested_tool, "callId": call_id }, + "hint": "요청한 도구가 아닌 다른 도구의 결과로 보입니다. calls[].tool 을 그대로 실행하세요.", + "doNot": "다른 Figma 도구로 대체하거나, 결과를 가공해 형식을 맞추려 하지 마세요." + }), + )) +} + +/// Finds only Figma's complete fixed `get_metadata` reminder, recursively, +/// so official results remain detectable through host-added JSON wrappers. +/// It deliberately ignores every other metadata-looking string. +fn contains_get_metadata_tail(value: &Value) -> bool { + match value { + Value::String(text) => text.contains(GET_METADATA_RESULT_TAIL), + Value::Object(object) => object.values().any(contains_get_metadata_tail), + Value::Array(values) => values.iter().any(contains_get_metadata_tail), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } +} + +/// Removes Figma's fixed reminder from every top-level `content[].text` +/// block before XML or text fallback parsing. +/// +/// This addresses clients that discard `structuredContent` and expose only +/// official Figma text. It truncates at the exact Figma-authored marker and +/// trims whitespace immediately before it; all other fields and all text +/// before the marker remain unchanged. It never creates envelope fields or +/// attempts to infer metadata. +fn strip_get_metadata_tail(value: &mut Value) { + let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + for item in content { + let Some(Value::String(text)) = item.get_mut("text") else { + continue; + }; + let Some(marker_start) = text.find(GET_METADATA_RESULT_TAIL) else { + continue; + }; + text.truncate(marker_start); + text.truncate(text.trim_end().len()); + } +} + /// Normalizes a `devup_figma_continue` `result` payload before it reaches /// the collector. This is the fix for a real observed failure: opencode's /// host handoff flattens an official Figma MCP `CallToolResult` down to @@ -566,3 +634,36 @@ fn received_shape(value: &Value) -> Value { content_types.dedup(); json!({ "topLevelKeys": top_level_keys, "contentTypes": content_types }) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{GET_METADATA_RESULT_TAIL, strip_get_metadata_tail}; + + /// Every text content block loses only the fixed reminder while values + /// outside `content[].text` remain byte-for-byte unchanged. + #[test] + fn strips_get_metadata_reminder_from_every_text_content_item_only() { + let mut value = json!({ + "content": [ + {"type": "text", "text": format!("first\n\n{GET_METADATA_RESULT_TAIL}")}, + {"type": "image", "data": "image-bytes"}, + {"type": "text", "text": format!("second \n{GET_METADATA_RESULT_TAIL}")}, + {"type": "text", "text": "unchanged"} + ], + "structuredContent": {"reminder": GET_METADATA_RESULT_TAIL} + }); + + strip_get_metadata_tail(&mut value); + + assert_eq!(value["content"][0]["text"], "first"); + assert_eq!(value["content"][1]["data"], "image-bytes"); + assert_eq!(value["content"][2]["text"], "second"); + assert_eq!(value["content"][3]["text"], "unchanged"); + assert_eq!( + value["structuredContent"]["reminder"], + GET_METADATA_RESULT_TAIL + ); + } +} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index ff8438f..e81566c 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -352,7 +352,7 @@ impl DevupServer { } #[tool( - description = "Convert a Figma design link to deterministic DevupUI TypeScript", + description = "Convert a Figma design link to deterministic DevupUI TypeScript only; use devup_figma_export when tokens or a source map are also needed, and never hand-interpret a handoff node tree", output_schema = permissive_object_output_schema() )] async fn devup_figma_to_ui( @@ -435,7 +435,7 @@ impl DevupServer { } #[tool( - description = "Search Figma pages, sections, frames, and components by name", + description = "Search Figma pages, sections, frames, and components by name to locate the target before devup_figma_export", output_schema = permissive_object_output_schema() )] async fn devup_figma_search( @@ -469,7 +469,7 @@ impl DevupServer { } #[tool( - description = "Explore screen candidates spatially related to a linked Figma node", + description = "Explore screen candidates spatially related to a linked Figma node to locate the right screen before devup_figma_export", output_schema = permissive_object_output_schema() )] async fn devup_figma_explore( @@ -515,7 +515,7 @@ impl DevupServer { } #[tool( - description = "Continue a read-only Figma host handoff with an official MCP result", + description = "Continue a read-only Figma host handoff: run calls[].tool with the exact arguments and pass its raw result unchanged; never substitute tools or fabricate envelope fields", output_schema = permissive_object_output_schema() )] async fn devup_figma_continue( @@ -539,7 +539,7 @@ impl DevupServer { } #[tool( - description = "Acquire a Figma design once and project multiple DevupUI artifacts", + description = "Acquire a Figma design once and project tsx/devupJson/sourceMap/rawSnapshot together in one collection; the primary Figma-to-code entry point, preferred over devup_figma_to_ui for implementation", output_schema = permissive_object_output_schema() )] async fn devup_figma_export( @@ -875,7 +875,15 @@ impl ServerHandler for DevupServer { .build(), ) .with_server_info(Implementation::new("devup-mcp", env!("CARGO_PKG_VERSION"))) - .with_instructions("Read Figma designs and generate DevupUI artifacts") + .with_instructions( + "1. Figma 디자인을 코드로 옮기는 작업에서 devup-mcp가 1차 소스다. 다른 출처로 이를 대체하지 마라.\n\ + 2. 구현이 목적이면 devup_figma_export를 먼저 호출하고 tsx, rawSnapshot, sourceMap을 함께 받아라.\n\ + 3. get_design_context, 스크린샷, 시각적 추론은 검증 보조일 뿐이다. devup-mcp 출력을 덮어쓰지 마라.\n\ + 4. 핸드오프로 받은 노드 트리를 직접 해석해 devup-ui 코드를 작성하지 마라. 좌표로 레이아웃을 추론하지 마라.\n\ + 5. 핸드오프 단계에서는 요청된 도구를 요청된 arguments 그대로 실행하고, 원본 결과를 가공 없이 devup_figma_continue로 돌려줘라.\n\ + 6. devup-mcp 호출이 실패하면 명시적으로 기록하라. 조용히 다른 방법으로 우회하지 마라.\n\ + 7. 색상·간격·radius·타이포 같은 UI 수치를 추측하지 마라. 확보하지 못했으면 멈추고 보고하라.", + ) } async fn list_resources( diff --git a/crates/devup-mcp/tests/handoff.rs b/crates/devup-mcp/tests/handoff.rs index 258805e..9e20b78 100644 --- a/crates/devup-mcp/tests/handoff.rs +++ b/crates/devup-mcp/tests/handoff.rs @@ -53,6 +53,20 @@ fn metadata_result() -> Value { }) } +/// Builds the content-only XML shape exposed when an MCP client drops +/// `structuredContent`, optionally with Figma's fixed `get_metadata` reminder. +fn xml_metadata_result(append_tail: bool) -> Value { + let xml = r#""#; + let text = if append_tail { + format!( + "{xml}\n\nIMPORTANT: After you call this tool, you MUST call get_design_context if trying to implement the design, since this tool only returns metadata. If you do not call get_design_context, the agent will not be able to implement the design." + ) + } else { + xml.to_owned() + }; + json!({"content": [{"type": "text", "text": text}]}) +} + fn snapshot_result() -> Value { json!({ "fileKey": "FileKey123", @@ -349,6 +363,104 @@ async fn stringified_tool_results_are_normalized_at_the_handoff_boundary() { assert_eq!(parts.snapshot_chunks.len(), 1); } +/// Reproduces WQUW-156: opencode preserved only the XML text plus Figma's +/// reminder, then submitted that `get_metadata` result for the next +/// `use_figma` call; the boundary must identify the wrong tool explicitly. +#[tokio::test] +async fn wquw_156_wrong_tool_result_reports_tool_mismatch_after_text_only_metadata() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "get_metadata"); + + store + .accept(&id, &calls[0].call_id, xml_metadata_result(true)) + .await + .unwrap(); + + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "use_figma"); + + let error = store + .accept(&id, &calls[0].call_id, xml_metadata_result(true)) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); + assert_eq!(error.details["reason"], "tool_mismatch"); + assert_eq!(error.details["requested"]["tool"], "use_figma"); +} + +/// Locks in the pre-existing XML fallback when the official reminder is not +/// present, so reminder normalization cannot regress ordinary text-only hosts. +#[tokio::test] +async fn content_only_xml_metadata_without_figma_reminder_advances_to_use_figma() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "get_metadata"); + + store + .accept(&id, &calls[0].call_id, xml_metadata_result(false)) + .await + .unwrap(); + + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "use_figma"); +} + +/// A mismatch rejection must preserve the exact pending call so the host can +/// retry with the requested tool's raw result instead of restarting collection. +#[tokio::test] +async fn tool_mismatch_rejection_keeps_use_figma_call_pending_for_corrected_result() { + let store = HandoffStore::with_limits(limits()); + let id = store + .begin(PendingOperation::Collect, collector()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + store + .accept(&id, &calls[0].call_id, metadata_result()) + .await + .unwrap(); + let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(calls[0].tool, "use_figma"); + + let error = store + .accept(&id, &calls[0].call_id, xml_metadata_result(true)) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); + assert_eq!(error.details["reason"], "tool_mismatch"); + assert_eq!(error.details["requested"]["tool"], "use_figma"); + + store + .accept(&id, &calls[0].call_id, snapshot_result()) + .await + .unwrap(); + let HandoffStep::Complete { parts, .. } = store.next(&id).await.unwrap() else { + panic!() + }; + assert_eq!(parts.snapshot_chunks.len(), 1); +} + #[tokio::test] async fn rejects_cross_session_calls_and_concurrent_replays() { let store = HandoffStore::with_limits(limits()); From 77c89369f3176f2de8ec2bbed935dc377dadba86 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 00:27:01 +0900 Subject: [PATCH 04/69] feat: skip Dynamic Client Registration when a pre-registered Figma client is provided devup-mcp still cannot register its own OAuth client with Figma's Remote MCP Catalog (client_name "devup-mcp" is not on the allowlist), and this change does not try to work around that by impersonating another product. Instead it gives operators an escape hatch: supply a client_id/client_secret that *is* already registered, and devup-mcp skips DCR entirely. - devup-mcp-figma: OAuthManager resolves client credentials from (in priority order) a static override, then a pluggable ClientCredentialStore (KeyringClientCredentialStore in production, MemoryClientCredentialStore for tests). When resolved, login() skips the /register POST and goes straight to authorization_code + PKCE, including client_secret in the token/refresh exchange when present. When unresolved, DCR still POSTs the honest, literal client_name "devup-mcp" (never Codex/Claude Code/etc.), and a rejection is now classified via UpstreamFailureContext::RegisterClient into a DEVUP_FIGMA_CATALOG_REJECTED error carrying four actionable options (configure, waitlist, local Dev Mode MCP, host handoff) without ever echoing the raw upstream body. - Callback listener port is now configurable via with_callback_port; a fixed port that's already in use fails immediately with DEVUP_FIGMA_CALLBACK_PORT_IN_USE instead of silently binding port 0 or waiting on a connection that will never arrive. redirect_uri generation is unchanged (still exactly http://127.0.0.1:/callback). - New OAuthManager::direct_path_snapshot()/configure_client_credentials() back devup_figma_auth's new "configure" action and a richer "doctor" action: paths.direct now reports credentialSource (cli-arg/env/ credential-store/none), tokenState (valid/expired/absent), and a measured callbackPort {port, free}. DevupAuth gained default-impl'd direct_path_snapshot/configure_client_credentials so existing external implementors keep compiling unchanged. - devup-mcp: ServerConfig/CLI gain --figma-client-id, --figma-client-secret, --figma-callback-port; DEVUP_FIGMA_CLIENT_ID/DEVUP_FIGMA_CLIENT_SECRET are read via a pure resolve_figma_direct_config() (env values passed in, not read internally) so the priority resolution stays unit-testable without mutating real process environment. - Secrets: client_secret is never included in DirectPathSnapshot, doctor output, error details, or Debug output (ClientCredentials redacts it like the existing SecretString/StoredAuthorization types); regression tests pin this at both the devup-mcp-figma and devup-mcp layers. Tests: devup-mcp-figma/tests/oauth_flow.rs (DCR skipped when credentials resolve; honest client_name + classified 403 with options when they don't; occupied fixed port fails in <5s instead of waiting on the 120s callback timeout; direct_path_snapshot reflects credential source/token state/callback port; secret redaction). devup-mcp/tests/cli.rs (new flags parse/validate; resolve_figma_direct_config priority). devup-mcp/tests/ figma_doctor.rs (doctor's new fields via default and custom DevupAuth impls; configure action persists/rejects/never echoes). devup-mcp/src/ server/diagnostics.rs unit tests (doctor_report signature + secret non-exposure). All pre-existing regressions kept green (boolean schema, hostRequirement, deliverable.isFinal, tool_mismatch, text fallback). Verified: cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings, cargo test --workspace --all-features, cargo insta test --workspace --all-features --check, cargo build --workspace --release -- all clean. --- README.md | 22 +- crates/devup-mcp-figma/src/credentials.rs | 109 +++++++- crates/devup-mcp-figma/src/errors.rs | 1 + crates/devup-mcp-figma/src/lib.rs | 9 +- crates/devup-mcp-figma/src/oauth.rs | 308 ++++++++++++++++++--- crates/devup-mcp-figma/src/source.rs | 16 +- crates/devup-mcp-figma/tests/oauth_flow.rs | 280 ++++++++++++++++++- crates/devup-mcp/src/lib.rs | 144 +++++++++- crates/devup-mcp/src/server/diagnostics.rs | 110 +++++++- crates/devup-mcp/src/server/mod.rs | 114 +++++++- crates/devup-mcp/src/server/tools.rs | 16 +- crates/devup-mcp/tests/cli.rs | 124 ++++++++- crates/devup-mcp/tests/figma_doctor.rs | 196 ++++++++++++- 13 files changed, 1362 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 98e3040..4fc0a28 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,13 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. { "status": "disconnected", "paths": { - "direct": { "available": false, "reason": "저장된 자격증명 없음. ..." }, + "direct": { + "available": false, + "credentialSource": "none", + "tokenState": "absent", + "callbackPort": { "port": null, "free": null }, + "reason": "저장된 자격증명 없음. ..." + }, "localDevMode": { "endpoint": "http://127.0.0.1:3845/mcp", "reachable": false, "hint": "..." }, "hostHandoff": { "expectedTool": "use_figma", "note": "..." } }, @@ -91,7 +97,17 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. } ``` -`paths.localDevMode.reachable`은 `127.0.0.1:3845`에 대한 300ms 이내 로컬 TCP 연결 확인 결과이며 실패해도 오류를 던지지 않습니다. `needs_figma` 응답에도 같은 프로브 결과가 `hostRequirement.localDevMode`로 포함됩니다. 자세한 제약과 3가지 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. +`paths.localDevMode.reachable`은 `127.0.0.1:3845`에 대한 300ms 이내 로컬 TCP 연결 확인 결과이며 실패해도 오류를 던지지 않습니다. `needs_figma` 응답에도 같은 프로브 결과가 `hostRequirement.localDevMode`로 포함됩니다. `paths.direct.credentialSource`는 `cli-arg`, `env`, `credential-store`, `none` 중 하나이고, `tokenState`는 `valid`, `expired`, `absent` 중 하나이며, `callbackPort`는 `--figma-callback-port`를 지정했을 때만 실측한 `port`/`free`를 담습니다. 자세한 제약과 3가지 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. + +### direct 경로에 사전 등록된 client 자격증명 주입하기 + +Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급받은 client)의 `client_id`/`client_secret`을 이미 가지고 있다면, devup-mcp에 다음 세 가지 방법 중 하나로 주입해 Dynamic Client Registration을 완전히 건너뛸 수 있습니다. 우선순위는 시작 인자 > 환경변수 > `configure`로 저장한 값입니다. + +- **시작 인자**: `devup-mcp --figma-client-id --figma-client-secret ` +- **환경변수**: `DEVUP_FIGMA_CLIENT_ID`, `DEVUP_FIGMA_CLIENT_SECRET` +- **도구**: `devup_figma_auth { "action": "configure", "clientId": "...", "clientSecret": "..." }` — OS credential store(시작 인자/환경변수와는 별도 항목)에 저장되어 프로세스를 재시작해도 유지됩니다. + +자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 기존과 동일하게 DCR을 시도하고, 403이면 host 핸드오프로 폴백합니다(하위호환 유지). devup-mcp는 자격증명이 있든 없든 DCR 요청의 `client_name`을 항상 정직하게 `"devup-mcp"`로 보냅니다 — 스스로를 `Codex`나 `Claude Code` 같은 다른 제품으로 신고하지 않습니다. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. ## Figma 연결 설정 @@ -132,6 +148,8 @@ Figma Remote MCP 등록 엔드포인트는 `POST https://api.figma.com/v1/oauth/ 로컬 OAuth 콜백이 쓰는 포트를 OS나 보안 소프트웨어(예: 사내 보안 에이전트)가 이미 점유하고 있으면, 브라우저는 리다이렉트에 "성공"한 것처럼 보이지만 그 요청은 다른 프로세스로 전달됩니다. 클라이언트는 **아무 에러 없이** `Waiting for authorization...` 상태로 영원히 남습니다. 로그인이 멈춘 것처럼 보이면 가장 먼저 콜백 포트를 다른 프로세스가 쓰고 있지 않은지 확인하세요. +기본값은 OS가 매번 빈 임시 포트를 골라주므로(`0`) 이 충돌을 피합니다. 사전 등록한 client의 `redirect_uri`가 고정 포트로 등록되어 있어 특정 포트를 고정해야 한다면 `devup-mcp --figma-callback-port `를 지정하세요. 이 경우 devup-mcp는 그 포트가 이미 사용 중이면 **연결을 기다리지 않고** `DEVUP_FIGMA_CALLBACK_PORT_IN_USE` 오류를 즉시 반환합니다. `devup_figma_auth { "action": "doctor" }`의 `paths.direct.callbackPort.free`에서도 지정한 포트가 실제로 비어 있는지 실측한 값을 확인할 수 있습니다. + ### opencode에서 direct 경로 미리 설정하기 Dynamic Client Registration을 건너뛰려면 `mcp..oauth`에 이미 발급받은 `clientId`/`clientSecret`을 직접 지정합니다. diff --git a/crates/devup-mcp-figma/src/credentials.rs b/crates/devup-mcp-figma/src/credentials.rs index 826b45c..5dfe9ca 100644 --- a/crates/devup-mcp-figma/src/credentials.rs +++ b/crates/devup-mcp-figma/src/credentials.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -use super::{DevupError, ErrorCode}; +use super::{DevupError, ErrorCode, SecretString}; #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -130,3 +130,110 @@ fn credential_task_error() -> DevupError { true, ) } + +/// A user-supplied, pre-registered Figma Remote MCP OAuth client (see +/// `README.md`'s "Figma 연결 설정" section for why devup-mcp cannot +/// register its own client). devup-mcp never invents this value: it is +/// only ever accepted from `--figma-client-id`/`--figma-client-secret`, +/// `DEVUP_FIGMA_CLIENT_ID`/`DEVUP_FIGMA_CLIENT_SECRET`, or the +/// `devup_figma_auth {"action":"configure"}` tool. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCredentials { + pub client_id: String, + pub client_secret: Option, +} + +impl std::fmt::Debug for ClientCredentials { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClientCredentials") + .field("client_id", &self.client_id) + .field( + "client_secret", + &self.client_secret.as_ref().map(|_| "[REDACTED]"), + ) + .finish() + } +} + +/// Persists a user-supplied [`ClientCredentials`] so it survives process +/// restarts, independent of the OAuth token stored in [`CredentialStore`]. +#[async_trait] +pub trait ClientCredentialStore: Send + Sync + 'static { + async fn load(&self) -> Result, DevupError>; + async fn save(&self, value: &ClientCredentials) -> Result<(), DevupError>; + async fn clear(&self) -> Result<(), DevupError>; +} + +#[derive(Clone, Default)] +pub struct MemoryClientCredentialStore { + value: Arc>>, +} + +#[async_trait] +impl ClientCredentialStore for MemoryClientCredentialStore { + async fn load(&self) -> Result, DevupError> { + Ok(self.value.read().await.clone()) + } + + async fn save(&self, value: &ClientCredentials) -> Result<(), DevupError> { + *self.value.write().await = Some(value.clone()); + Ok(()) + } + + async fn clear(&self) -> Result<(), DevupError> { + *self.value.write().await = None; + Ok(()) + } +} + +/// OS credential store backend for [`ClientCredentialStore`]. Uses a +/// distinct keyring entry from [`KeyringCredentialStore`] (which holds the +/// OAuth token) so configuring a client credential never touches the +/// stored access/refresh token, and vice versa. +#[derive(Debug, Clone, Copy, Default)] +pub struct KeyringClientCredentialStore; + +impl KeyringClientCredentialStore { + fn entry() -> Result { + keyring::Entry::new("devup-mcp", "figma-client-credentials").map_err(keyring_error) + } +} + +#[async_trait] +impl ClientCredentialStore for KeyringClientCredentialStore { + async fn load(&self) -> Result, DevupError> { + tokio::task::spawn_blocking(|| match Self::entry()?.get_password() { + Ok(json) => serde_json::from_str(&json).map(Some).map_err(|_| { + DevupError::new( + ErrorCode::DevupAuthRequired, + "저장된 Figma client 자격증명을 읽을 수 없습니다. 다시 configure하세요.", + false, + ) + }), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(keyring_error(error)), + }) + .await + .map_err(|_| credential_task_error())? + } + + async fn save(&self, value: &ClientCredentials) -> Result<(), DevupError> { + let json = serde_json::to_string(value).map_err(|_| credential_task_error())?; + tokio::task::spawn_blocking(move || { + Self::entry()?.set_password(&json).map_err(keyring_error) + }) + .await + .map_err(|_| credential_task_error())? + } + + async fn clear(&self) -> Result<(), DevupError> { + tokio::task::spawn_blocking(|| match Self::entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(keyring_error(error)), + }) + .await + .map_err(|_| credential_task_error())? + } +} diff --git a/crates/devup-mcp-figma/src/errors.rs b/crates/devup-mcp-figma/src/errors.rs index e9618b8..6280705 100644 --- a/crates/devup-mcp-figma/src/errors.rs +++ b/crates/devup-mcp-figma/src/errors.rs @@ -6,6 +6,7 @@ pub enum ErrorCode { DevupAuthRequired, DevupAuthCallbackTimeout, DevupAuthStateMismatch, + DevupFigmaCallbackPortInUse, DevupFigmaPermissionDenied, DevupFigmaRateLimited, DevupFigmaDirectUnavailable, diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 3c0ab8f..ede5604 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -22,7 +22,9 @@ pub use collector::{ }; pub use credentials::{ - CredentialStore, KeyringCredentialStore, MemoryCredentialStore, StoredAuthorization, + ClientCredentialStore, ClientCredentials, CredentialStore, KeyringClientCredentialStore, + KeyringCredentialStore, MemoryClientCredentialStore, MemoryCredentialStore, + StoredAuthorization, }; pub use envelope::{ FastSnapshotPayload, FastThemePayload, FastTransportStats, decode_fast_multi_snapshot, @@ -39,7 +41,10 @@ pub use large_values::{ LargeValueReadOptions, LargeValueUnsupported, MAX_LARGE_VALUE_BYTES, MAX_LARGE_VALUE_CHUNK_BYTES, }; -pub use oauth::{AuthStatus, BrowserOpener, OAuthManager, SecretString, SystemBrowser}; +pub use oauth::{ + AuthStatus, BrowserOpener, ClientCredentialSource, DirectPathSnapshot, OAuthManager, + SecretString, SystemBrowser, TokenState, +}; pub use payload::{ CollectedPayload, PayloadCompleteness, PayloadCompletenessReport, PayloadStructure, ResourceAudit, validate_payload_context, diff --git a/crates/devup-mcp-figma/src/oauth.rs b/crates/devup-mcp-figma/src/oauth.rs index 171f33a..47e1b91 100644 --- a/crates/devup-mcp-figma/src/oauth.rs +++ b/crates/devup-mcp-figma/src/oauth.rs @@ -1,4 +1,7 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rand::Rng; @@ -11,7 +14,11 @@ use tokio::{ }; use url::Url; -use super::{CredentialStore, DevupError, ErrorCode, StoredAuthorization}; +use super::{ + ClientCredentialStore, ClientCredentials, CredentialStore, DevupError, ErrorCode, + MemoryClientCredentialStore, StoredAuthorization, UpstreamFailureContext, + upstream_failure_error, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -20,6 +27,45 @@ pub enum AuthStatus { Disconnected, } +/// Where a resolved [`ClientCredentials`] came from, reported by +/// `devup_figma_auth {"action":"doctor"}` so an agent (or human) can tell +/// *why* a particular client is in play without ever seeing the secret +/// itself. See `README.md`'s "Figma 연결 설정" for the three supported +/// injection paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClientCredentialSource { + CliArg, + Env, + CredentialStore, + #[default] + None, +} + +/// Freshness of the OAuth token in the [`CredentialStore`], independent of +/// whether a [`ClientCredentials`] is configured. `Expired` still means a +/// refresh is possible if a `refresh_token` was stored; it does not by +/// itself make `direct` unavailable (see `AuthStatus`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TokenState { + Valid, + Expired, + Absent, +} + +/// Everything `doctor` needs to describe the `direct` connection path +/// without ever including the client secret or access/refresh tokens +/// themselves — only their provenance and state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectPathSnapshot { + pub credential_source: ClientCredentialSource, + pub token_state: TokenState, + pub callback_port: Option, + pub callback_port_free: Option, +} + pub trait BrowserOpener: Send + Sync { fn open(&self, authorization_url: &str) -> Result<(), DevupError>; } @@ -40,10 +86,14 @@ impl BrowserOpener for SystemBrowser { } } -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct SecretString(String); impl SecretString { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + pub fn expose(&self) -> &str { &self.0 } @@ -61,6 +111,12 @@ pub struct OAuthManager { store: S, client: reqwest::Client, callback_timeout: Duration, + callback_port: Option, + /// A cli-arg/env-supplied override. Always wins over + /// `client_credential_store` when present; its `ClientCredentialSource` + /// is always `CliArg` or `Env`. + static_client_credentials: Option<(ClientCredentials, ClientCredentialSource)>, + client_credential_store: Arc, } impl OAuthManager { @@ -76,6 +132,9 @@ impl OAuthManager { store, client, callback_timeout: Duration::from_secs(180), + callback_port: None, + static_client_credentials: None, + client_credential_store: Arc::new(MemoryClientCredentialStore::default()), } } @@ -84,6 +143,37 @@ impl OAuthManager { self } + /// Fixes the local OAuth callback listener to a specific port instead + /// of letting the OS assign a free one. Required when a pre-registered + /// client's `redirect_uri` was registered with an exact port. `None` + /// (the default) preserves the pre-existing OS-assigned-port behavior. + pub fn with_callback_port(mut self, port: Option) -> Self { + self.callback_port = port; + self + } + + /// Installs a cli-arg/env-supplied client credential override. This + /// always takes priority over anything in `client_credential_store`, + /// and causes `login` to skip Dynamic Client Registration entirely. + pub fn with_static_client_credentials( + mut self, + credentials: ClientCredentials, + source: ClientCredentialSource, + ) -> Self { + self.static_client_credentials = Some((credentials, source)); + self + } + + /// Installs the backend used to persist client credentials configured + /// via [`Self::configure_client_credentials`]. Defaults to an + /// in-process-only store so `configure` still works without explicit + /// wiring in tests; production code should pass a + /// `KeyringClientCredentialStore`. + pub fn with_client_credential_store(mut self, store: Arc) -> Self { + self.client_credential_store = store; + self + } + pub async fn status(&self) -> Result { Ok(if self.store.load().await?.is_some() { AuthStatus::Connected @@ -96,39 +186,121 @@ impl OAuthManager { self.store.clear().await } + /// Persists a user-supplied client credential (from the + /// `devup_figma_auth {"action":"configure"}` tool) so subsequent + /// `login` calls skip Dynamic Client Registration, even across process + /// restarts, without requiring `--figma-client-id`/`DEVUP_FIGMA_CLIENT_ID` + /// on every launch. + pub async fn configure_client_credentials( + &self, + client_id: String, + client_secret: Option, + ) -> Result<(), DevupError> { + let credentials = ClientCredentials { + client_id, + client_secret: client_secret.map(SecretString), + }; + self.client_credential_store.save(&credentials).await + } + + /// Resolves the client credential that `login`/`refresh` should use, + /// in priority order: cli-arg/env override, then the persisted + /// client-credential store, then `None` (Dynamic Client Registration). + async fn resolve_client_credentials( + &self, + ) -> Result, DevupError> { + if let Some((credentials, source)) = &self.static_client_credentials { + return Ok(Some((credentials.clone(), *source))); + } + if let Some(credentials) = self.client_credential_store.load().await? { + return Ok(Some((credentials, ClientCredentialSource::CredentialStore))); + } + Ok(None) + } + + async fn token_state(&self) -> Result { + Ok(match self.store.load().await? { + None => TokenState::Absent, + Some(authorization) => match authorization.expires_at { + Some(expires_at) if expires_at <= now() => TokenState::Expired, + _ => TokenState::Valid, + }, + }) + } + + /// Builds the `paths.direct` snapshot for `devup_figma_auth + /// {"action":"doctor"}`: which credential is in play (never the secret + /// itself), whether the stored token is still fresh, and — when a + /// fixed callback port is configured — whether it is actually free + /// right now (measured, not assumed). + pub async fn direct_path_snapshot(&self) -> Result { + let credential_source = self + .resolve_client_credentials() + .await? + .map(|(_, source)| source) + .unwrap_or_default(); + let token_state = self.token_state().await?; + let callback_port_free = match self.callback_port { + Some(port) => Some(probe_callback_port_free(port).await), + None => None, + }; + Ok(DirectPathSnapshot { + credential_source, + token_state, + callback_port: self.callback_port, + callback_port_free, + }) + } + pub async fn login( &self, opener: &dyn BrowserOpener, ) -> Result { let metadata = self.discover().await?; - let listener = TcpListener::bind("127.0.0.1:0") - .await - .map_err(callback_error)?; + let listener = bind_callback_listener(self.callback_port).await?; let redirect_uri = format!( "http://127.0.0.1:{}/callback", listener.local_addr().map_err(callback_error)?.port() ); - let registration: RegistrationResponse = self - .client - .post(&metadata.registration_endpoint) - .json(&serde_json::json!({ - "client_name": "devup-mcp", - "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "application_type": "native", - "scope": "mcp:connect" - })) - .send() - .await - .map_err(auth_network_error)? - .error_for_status() - .map_err(auth_network_error)? - .json() - .await - .map_err(auth_network_error)?; + // A resolved client credential (cli-arg/env override or a + // previously `configure`d value) always skips Dynamic Client + // Registration. Otherwise devup-mcp registers itself honestly as + // "devup-mcp" — never as another product's name — and Figma's + // allowlist decides the outcome (see `README.md`). + let resolved = self.resolve_client_credentials().await?; + let (client_id, client_secret) = match resolved { + Some((credentials, _source)) => (credentials.client_id, credentials.client_secret), + None => { + let response = self + .client + .post(&metadata.registration_endpoint) + .json(&serde_json::json!({ + "client_name": "devup-mcp", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "native", + "scope": "mcp:connect" + })) + .send() + .await + .map_err(auth_network_error)?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(upstream_failure_error( + UpstreamFailureContext::RegisterClient, + Some(status.as_u16()), + &body, + )); + } + let registration: RegistrationResponse = + response.json().await.map_err(auth_network_error)?; + (registration.client_id, None) + } + }; let state = random_urlsafe(32); let verifier = random_urlsafe(64); @@ -138,7 +310,7 @@ impl OAuthManager { authorization_url .query_pairs_mut() .append_pair("response_type", "code") - .append_pair("client_id", ®istration.client_id) + .append_pair("client_id", &client_id) .append_pair("redirect_uri", &redirect_uri) .append_pair("scope", "mcp:connect") .append_pair("state", &state) @@ -148,17 +320,21 @@ impl OAuthManager { opener.open(authorization_url.as_str())?; let callback = receive_callback(listener, &state, self.callback_timeout).await?; + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", callback.code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ("code_verifier", verifier.as_str()), + ("resource", metadata.resource.as_str()), + ]; + if let Some(secret) = client_secret.as_ref() { + form.push(("client_secret", secret.expose())); + } let token: TokenResponse = self .client .post(&metadata.token_endpoint) - .form(&[ - ("grant_type", "authorization_code"), - ("client_id", registration.client_id.as_str()), - ("code", callback.code.as_str()), - ("redirect_uri", redirect_uri.as_str()), - ("code_verifier", verifier.as_str()), - ("resource", metadata.resource.as_str()), - ]) + .form(&form) .send() .await .map_err(auth_network_error)? @@ -169,7 +345,7 @@ impl OAuthManager { .map_err(auth_network_error)?; let authorization = StoredAuthorization { - client_id: registration.client_id, + client_id, access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token @@ -202,15 +378,23 @@ impl OAuthManager { .refresh_token .clone() .ok_or_else(auth_required)?; + let resolved = self.resolve_client_credentials().await?; + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", "refresh_token"), + ("client_id", authorization.client_id.as_str()), + ("refresh_token", refresh_token.as_str()), + ("resource", authorization.resource.as_str()), + ]; + if let Some(secret) = resolved + .as_ref() + .and_then(|(credentials, _source)| credentials.client_secret.as_ref()) + { + form.push(("client_secret", secret.expose())); + } let response: TokenResponse = self .client .post(&authorization.token_endpoint) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", authorization.client_id.as_str()), - ("refresh_token", refresh_token.as_str()), - ("resource", authorization.resource.as_str()), - ]) + .form(&form) .send() .await .map_err(auth_network_error)? @@ -393,6 +577,46 @@ async fn write_callback_response( .map_err(callback_error) } +/// Binds the local OAuth callback listener. When `port` is `None`, keeps +/// the pre-existing behavior of letting the OS assign a free ephemeral +/// port (`0`). When `port` is `Some`, the bind attempt itself is the +/// availability check: a fixed port that is already in use fails +/// immediately with [`callback_port_in_use_error`] instead of silently +/// waiting — binding is not retried and no listener that never receives a +/// connection is created. +async fn bind_callback_listener(port: Option) -> Result { + let requested_port = port.unwrap_or(0); + TcpListener::bind(("127.0.0.1", requested_port)) + .await + .map_err(|error| match port { + Some(configured_port) => callback_port_in_use_error(configured_port, error), + None => callback_error(error), + }) +} + +/// Best-effort probe for `doctor`: attempts to bind `port` and immediately +/// releases it. `true` means the port was free at the moment of the probe +/// (not a guarantee it stays free); `false` means something is already +/// listening there. Never blocks waiting for a connection. +pub async fn probe_callback_port_free(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).await.is_ok() +} + +fn callback_port_in_use_error(port: u16, _error: std::io::Error) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaCallbackPortInUse, + format!( + "설정된 Figma 인증 콜백 포트 {port}을(를) 다른 프로세스가 이미 사용하고 있습니다. \ + OS나 보안 소프트웨어가 이 포트를 점유하고 있으면 브라우저는 리다이렉트에 성공한 \ + 것처럼 보이지만 요청이 devup-mcp가 아닌 다른 프로세스로 전달되어 인증이 끝나지 \ + 않습니다. 포트를 점유한 프로세스를 종료하거나 --figma-callback-port로 다른 포트를 \ + 지정하세요." + ), + false, + serde_json::json!({ "port": port }), + ) +} + fn protected_resource_url(endpoint: &Url) -> Url { let mut url = endpoint.clone(); url.set_query(None); diff --git a/crates/devup-mcp-figma/src/source.rs b/crates/devup-mcp-figma/src/source.rs index 61eb6c5..9eaf64f 100644 --- a/crates/devup-mcp-figma/src/source.rs +++ b/crates/devup-mcp-figma/src/source.rs @@ -162,11 +162,15 @@ impl UpstreamFailureKind { false, ), }; - DevupError::with_details( - code, - message, - retryable, - json!({ "source": "direct", "status": status }), - ) + let mut details = json!({ "source": "direct", "status": status }); + if self == Self::CatalogRejected { + details["options"] = json!([ + "Figma MCP Catalog waitlist에 devup-mcp 등록: https://www.figma.com/mcp-catalog/", + "devup_figma_auth { action: \"configure\", clientId, clientSecret }로 직접 확보한 client 자격증명 주입", + "Figma 데스크톱 앱의 로컬 Dev Mode MCP 사용 (OAuth 불필요)", + "호스트에 등록된 공식 Figma MCP로 handoff (sourcePolicy: auto 또는 host, 현재 기본 폴백)" + ]); + } + DevupError::with_details(code, message, retryable, details) } } diff --git a/crates/devup-mcp-figma/tests/oauth_flow.rs b/crates/devup-mcp-figma/tests/oauth_flow.rs index 522f007..0d7c47f 100644 --- a/crates/devup-mcp-figma/tests/oauth_flow.rs +++ b/crates/devup-mcp-figma/tests/oauth_flow.rs @@ -3,10 +3,13 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use axum::{ Json, Router, extract::{Form, State}, + http::StatusCode, routing::{get, post}, }; use devup_mcp_figma::{ - AuthStatus, BrowserOpener, CredentialStore, MemoryCredentialStore, OAuthManager, + AuthStatus, BrowserOpener, ClientCredentialSource, ClientCredentials, CredentialStore, + DirectPathSnapshot, ErrorCode, MemoryClientCredentialStore, MemoryCredentialStore, + OAuthManager, SecretString, TokenState, }; use serde_json::{Value, json}; use tokio::{net::TcpListener, sync::Mutex}; @@ -164,3 +167,278 @@ async fn logout_clears_persisted_authorization() -> anyhow::Result<()> { assert_eq!(manager.status().await?, AuthStatus::Disconnected); Ok(()) } + +async fn register_forbidden( + State(state): State, + Json(body): Json, +) -> (StatusCode, String) { + *state.captured.registration.lock().await = Some(body); + // Real Figma returns a *plain-text* 403 body, not JSON — this is the + // exact shape that broke naive OAuth clients (see README.md). The + // fixture reproduces it so tests exercise the real failure mode. + (StatusCode::FORBIDDEN, "Forbidden".to_owned()) +} + +async fn spawn_mock_oauth_server( + register: axum::routing::MethodRouter, +) -> anyhow::Result<(String, Captured)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let base = format!("http://{}", listener.local_addr()?); + let captured = Captured::default(); + let app = Router::new() + .route( + "/.well-known/oauth-protected-resource/mcp", + get(protected_resource), + ) + .route( + "/.well-known/oauth-authorization-server", + get(authorization_metadata), + ) + .route("/register", register) + .route("/token", post(token)) + .with_state(AppState { + base: base.clone(), + captured: captured.clone(), + }); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock OAuth server"); + }); + Ok((base, captured)) +} + +/// Core deliverable #1: when a pre-registered client credential is +/// resolvable (here via `with_static_client_credentials`, standing in for +/// `--figma-client-id`/`DEVUP_FIGMA_CLIENT_ID`), `login` must skip +/// Dynamic Client Registration entirely — the `/register` endpoint must +/// never be called — and use the given `client_id`/`client_secret` for the +/// PKCE authorization-code exchange. +#[tokio::test] +async fn static_client_credentials_skip_dynamic_client_registration() -> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(3)) + .with_static_client_credentials( + ClientCredentials { + client_id: "preregistered-client".to_owned(), + client_secret: Some(SecretString::new("preregistered-secret")), + }, + ClientCredentialSource::CliArg, + ); + let authorization = manager.login(&CallbackOpener).await?; + + assert!( + captured.registration.lock().await.is_none(), + "DCR must never be attempted once a client credential resolves" + ); + assert_eq!(authorization.client_id, "preregistered-client"); + + let form = captured + .token_form + .lock() + .await + .clone() + .expect("token form"); + assert_eq!( + form.get("client_id").map(String::as_str), + Some("preregistered-client") + ); + assert_eq!( + form.get("client_secret").map(String::as_str), + Some("preregistered-secret") + ); + Ok(()) +} + +/// Core deliverable #3 (README honesty policy): with no client credential +/// resolvable, `login` still performs DCR with the literal, honest +/// `client_name: "devup-mcp"` (never impersonating another product), and a +/// 403 rejection (Figma's real response shape: plain-text `Forbidden`, not +/// JSON) surfaces as a classified, actionable `DEVUP_FIGMA_CATALOG_REJECTED` +/// error — not a generic network failure — carrying the four documented +/// options without ever echoing the raw upstream body. +#[tokio::test] +async fn dcr_403_is_classified_as_catalog_rejected_with_actionable_options() -> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register_forbidden)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(3)); + let error = manager + .login(&CallbackOpener) + .await + .expect_err("403 registration must fail login"); + + assert_eq!(error.code, ErrorCode::DevupFigmaCatalogRejected); + let options = error.details["options"] + .as_array() + .expect("catalog-rejected errors carry actionable options"); + assert_eq!(options.len(), 4); + assert!( + options + .iter() + .any(|option| option.as_str().unwrap_or_default().contains("configure")) + ); + assert!( + options + .iter() + .any(|option| option.as_str().unwrap_or_default().contains("mcp-catalog")) + ); + let serialized = serde_json::to_string(&error)?; + assert!(!serialized.contains("Forbidden")); + + // Never even attempted the DCR registration under a spoofed name; + // confirm the honest, literal request that *did* go out. + let registration = captured + .registration + .lock() + .await + .clone() + .expect("registration attempt"); + assert_eq!(registration["client_name"], "devup-mcp"); + Ok(()) +} + +/// Core deliverable #2: a *configured* callback port that is already +/// occupied must fail the bind attempt immediately with a specific, +/// actionable error — never silently wait for a connection that will +/// never arrive (the `MaEPSBroker.exe`-style trap documented in +/// README.md). +#[tokio::test] +async fn occupied_callback_port_fails_immediately_instead_of_waiting() -> anyhow::Result<()> { + let (base, _captured) = spawn_mock_oauth_server(post(register)).await?; + + // Bind a real listener to claim a genuinely free ephemeral port, then + // keep it alive so the manager's bind attempt on that exact port + // fails deterministically. + let occupier = TcpListener::bind("127.0.0.1:0").await?; + let occupied_port = occupier.local_addr()?.port(); + + let store = MemoryCredentialStore::default(); + // A generous timeout: if the implementation regressed to "wait for a + // connection", this test would hang for the full duration instead of + // returning within milliseconds. + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(120)) + .with_callback_port(Some(occupied_port)); + + let started = std::time::Instant::now(); + let error = manager + .login(&CallbackOpener) + .await + .expect_err("bind on an occupied fixed port must fail"); + let elapsed = started.elapsed(); + + assert_eq!(error.code, ErrorCode::DevupFigmaCallbackPortInUse); + assert!( + !error.retryable, + "occupied fixed port is not a retry-me error" + ); + assert_eq!(error.details["port"], occupied_port); + assert!( + elapsed < Duration::from_secs(5), + "must fail immediately on bind, not wait for the callback timeout: took {elapsed:?}" + ); + + drop(occupier); + Ok(()) +} + +/// Core deliverable #5 (`doctor`): `direct_path_snapshot` must reflect the +/// real, measured state — which credential source is active, whether the +/// stored token is still fresh, and whether a configured callback port is +/// actually free right now — without ever exposing the secret itself. +#[tokio::test] +async fn direct_path_snapshot_reports_measured_credential_and_port_state() -> anyhow::Result<()> { + let credential_store = MemoryClientCredentialStore::default(); + let manager = OAuthManager::with_endpoint( + "https://mcp.figma.com/mcp", + MemoryCredentialStore::default(), + ) + .with_client_credential_store(Arc::new(credential_store)); + + // Nothing configured yet: no credential, no token, no fixed port. + let absent = manager.direct_path_snapshot().await?; + assert_eq!(absent.credential_source, ClientCredentialSource::None); + assert_eq!(absent.token_state, TokenState::Absent); + assert_eq!(absent.callback_port, None); + assert_eq!(absent.callback_port_free, None); + + // `configure` persists a client credential; its source must now read + // "credential-store" (not "cli-arg"/"env" — those are for + // process-launch overrides only). + manager + .configure_client_credentials( + "configured-client".to_owned(), + Some("configured-secret".to_owned()), + ) + .await?; + let configured = manager.direct_path_snapshot().await?; + assert_eq!( + configured.credential_source, + ClientCredentialSource::CredentialStore + ); + let serialized = serde_json::to_string(&configured)?; + assert!(!serialized.contains("configured-secret")); + + Ok(()) +} + +/// `doctor`'s callback-port probe must reflect the real bind state: free +/// when unoccupied, occupied when another listener holds the exact port. +#[tokio::test] +async fn direct_path_snapshot_probes_the_real_callback_port_state() -> anyhow::Result<()> { + let manager = OAuthManager::with_endpoint( + "https://mcp.figma.com/mcp", + MemoryCredentialStore::default(), + ); + + let probe_listener = TcpListener::bind("127.0.0.1:0").await?; + let free_port = probe_listener.local_addr()?.port(); + drop(probe_listener); + let free = manager + .clone() + .with_callback_port(Some(free_port)) + .direct_path_snapshot() + .await?; + assert_eq!(free.callback_port, Some(free_port)); + assert_eq!(free.callback_port_free, Some(true)); + + let occupier = TcpListener::bind("127.0.0.1:0").await?; + let occupied_port = occupier.local_addr()?.port(); + let occupied = manager + .with_callback_port(Some(occupied_port)) + .direct_path_snapshot() + .await?; + assert_eq!(occupied.callback_port_free, Some(false)); + drop(occupier); + + Ok(()) +} + +/// Security regression: a client secret configured via any path +/// (`with_static_client_credentials` here, standing in for +/// `--figma-client-secret`/`DEVUP_FIGMA_CLIENT_SECRET`) must never appear +/// in `Debug` output of the credential itself or in any snapshot derived +/// from it. `DirectPathSnapshot` structurally has no field capable of +/// carrying it — this test pins that guarantee at the value level too. +#[test] +fn client_secret_never_appears_in_debug_output() { + let credentials = ClientCredentials { + client_id: "preregistered-client".to_owned(), + client_secret: Some(SecretString::new("super-secret-value")), + }; + let debugged = format!("{credentials:?}"); + assert!(!debugged.contains("super-secret-value")); + assert!(debugged.contains("REDACTED")); + + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::CliArg, + token_state: TokenState::Valid, + callback_port: Some(19876), + callback_port_free: Some(true), + }; + let serialized = serde_json::to_string(&snapshot).expect("snapshot serializes"); + assert!(!serialized.contains("super-secret-value")); +} diff --git a/crates/devup-mcp/src/lib.rs b/crates/devup-mcp/src/lib.rs index 0c90557..1f3205e 100644 --- a/crates/devup-mcp/src/lib.rs +++ b/crates/devup-mcp/src/lib.rs @@ -4,9 +4,78 @@ use std::{ffi::OsString, path::PathBuf}; use serde::Serialize; +pub use devup_mcp_figma::ClientCredentialSource; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerConfig { pub allowed_write_roots: Vec, + /// From `--figma-client-id`. `None` unless the flag was passed. + pub figma_client_id: Option, + /// From `--figma-client-secret`. `None` unless the flag was passed. + pub figma_client_secret: Option, + /// From `--figma-callback-port`. `None` preserves the pre-existing + /// OS-assigned-port behavior. + pub figma_callback_port: Option, +} + +/// Fully resolved Figma direct-connection configuration: cli-arg values +/// (if any) win over environment variables, which win over "nothing +/// configured here" (the persisted `configure` store, if any, is resolved +/// later inside `OAuthManager`, not here). Built by +/// [`resolve_figma_direct_config`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FigmaDirectConfig { + pub client_id: Option, + pub client_secret: Option, + pub credential_source: ClientCredentialSource, + pub callback_port: Option, +} + +/// Resolves the effective Figma direct-connection client credential from +/// (in priority order) cli-arg flags, then environment variables. Takes +/// the environment values as explicit parameters — rather than reading +/// `std::env::var` internally — so this stays a pure, deterministically +/// testable function; callers pass real env values at the process +/// boundary (see `run_stdio_with_config`, `self_check`). +pub fn resolve_figma_direct_config( + cli_client_id: Option, + cli_client_secret: Option, + cli_callback_port: Option, + env_client_id: Option, + env_client_secret: Option, +) -> FigmaDirectConfig { + if let Some(client_id) = cli_client_id { + return FigmaDirectConfig { + client_id: Some(client_id), + client_secret: cli_client_secret, + credential_source: ClientCredentialSource::CliArg, + callback_port: cli_callback_port, + }; + } + if let Some(client_id) = env_client_id { + return FigmaDirectConfig { + client_id: Some(client_id), + client_secret: env_client_secret, + credential_source: ClientCredentialSource::Env, + callback_port: cli_callback_port, + }; + } + FigmaDirectConfig { + callback_port: cli_callback_port, + ..FigmaDirectConfig::default() + } +} + +/// Reads `DEVUP_FIGMA_CLIENT_ID`/`DEVUP_FIGMA_CLIENT_SECRET`, treating an +/// empty value the same as an unset one. +fn env_figma_client_credentials() -> (Option, Option) { + let client_id = std::env::var("DEVUP_FIGMA_CLIENT_ID") + .ok() + .filter(|value| !value.is_empty()); + let client_secret = std::env::var("DEVUP_FIGMA_CLIENT_SECRET") + .ok() + .filter(|value| !value.is_empty()); + (client_id, client_secret) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -38,12 +107,19 @@ where { let mut arguments = arguments.into_iter().map(Into::into).peekable(); let mut roots = Vec::new(); + let mut figma_client_id: Option = None; + let mut figma_client_secret: Option = None; + let mut figma_callback_port: Option = None; while let Some(argument) = arguments.next() { + let no_other_options_yet = roots.is_empty() + && figma_client_id.is_none() + && figma_client_secret.is_none() + && figma_callback_port.is_none(); match argument.to_str() { - Some("--version" | "-V") if roots.is_empty() && arguments.peek().is_none() => { + Some("--version" | "-V") if no_other_options_yet && arguments.peek().is_none() => { return Ok(CliAction::Version); } - Some("--self-check") if roots.is_empty() && arguments.peek().is_none() => { + Some("--self-check") if no_other_options_yet && arguments.peek().is_none() => { return Ok(CliAction::SelfCheck); } Some("--allow-write-root") => { @@ -56,6 +132,47 @@ where } roots.push(root); } + Some("--figma-client-id") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-id에는 값이 필요합니다."))?; + let value = value + .to_str() + .ok_or_else(|| { + anyhow::anyhow!("--figma-client-id는 UTF-8 문자열이어야 합니다.") + })? + .to_owned(); + if value.is_empty() { + anyhow::bail!("--figma-client-id는 빈 문자열일 수 없습니다."); + } + figma_client_id = Some(value); + } + Some("--figma-client-secret") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-secret에는 값이 필요합니다."))?; + let value = value + .to_str() + .ok_or_else(|| { + anyhow::anyhow!("--figma-client-secret는 UTF-8 문자열이어야 합니다.") + })? + .to_owned(); + if value.is_empty() { + anyhow::bail!("--figma-client-secret는 빈 문자열일 수 없습니다."); + } + figma_client_secret = Some(value); + } + Some("--figma-callback-port") => { + let value = arguments.next().ok_or_else(|| { + anyhow::anyhow!("--figma-callback-port에는 포트 번호가 필요합니다.") + })?; + let value = value.to_str().ok_or_else(|| { + anyhow::anyhow!("--figma-callback-port는 UTF-8 문자열이어야 합니다.") + })?; + figma_callback_port = Some(value.parse::().map_err(|_| { + anyhow::anyhow!("--figma-callback-port는 1-65535 사이 숫자여야 합니다.") + })?); + } Some(flag) => anyhow::bail!("지원하지 않는 devup-mcp 인자입니다: {flag}"), None => anyhow::bail!("devup-mcp 인자는 UTF-8 flag여야 합니다."), } @@ -65,14 +182,20 @@ where } Ok(CliAction::Serve(ServerConfig { allowed_write_roots: roots, + figma_client_id, + figma_client_secret, + figma_callback_port, })) } pub fn self_check() -> SelfCheckReport { let credential_ok = devup_mcp_figma::KeyringCredentialStore::probe().is_ok(); + let (env_client_id, env_client_secret) = env_figma_client_credentials(); + let figma_direct = + resolve_figma_direct_config(None, None, None, env_client_id, env_client_secret); let server_ok = std::env::current_dir() .ok() - .and_then(|root| server::DevupServer::production_with_output_roots(vec![root]).ok()) + .and_then(|root| server::DevupServer::production_with_config(vec![root], figma_direct).ok()) .is_some(); SelfCheckReport { status: if credential_ok && server_ok { @@ -98,9 +221,18 @@ pub async fn run_stdio() -> anyhow::Result<()> { pub async fn run_stdio_with_config(config: ServerConfig) -> anyhow::Result<()> { use rmcp::ServiceExt; - let service = server::DevupServer::production_with_output_roots(config.allowed_write_roots)? - .serve((tokio::io::stdin(), tokio::io::stdout())) - .await?; + let (env_client_id, env_client_secret) = env_figma_client_credentials(); + let figma_direct = resolve_figma_direct_config( + config.figma_client_id.clone(), + config.figma_client_secret.clone(), + config.figma_callback_port, + env_client_id, + env_client_secret, + ); + let service = + server::DevupServer::production_with_config(config.allowed_write_roots, figma_direct)? + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await?; service.waiting().await?; Ok(()) } diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 26e51a4..7eccaef 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -29,7 +29,7 @@ use std::time::Duration; -use devup_mcp_figma::AuthStatus; +use devup_mcp_figma::{AuthStatus, ClientCredentialSource, DirectPathSnapshot}; use serde_json::{Value, json}; /// Loopback address the Figma desktop app's local Dev Mode MCP server binds @@ -128,7 +128,12 @@ pub async fn host_requirement() -> Value { /// an instruction to register under a specific product name. Registration /// is allowlisted by Figma outside devup-mcp's control; this only reports /// the constraint and points at the public waitlist. -pub async fn doctor_report(status: AuthStatus) -> Value { +/// +/// `direct` supplies the richer, measured detail behind `paths.direct`: +/// which credential source is in play (never the secret itself), whether +/// the stored token is fresh, and — when a fixed callback port is +/// configured — whether it is actually free right now. +pub async fn doctor_report(status: AuthStatus, direct: DirectPathSnapshot) -> Value { let reachable = local_dev_mode_reachable().await; let direct_available = status == AuthStatus::Connected; json!({ @@ -136,11 +141,13 @@ pub async fn doctor_report(status: AuthStatus) -> Value { "paths": { "direct": { "available": direct_available, - "reason": if direct_available { - "저장된 자격증명이 있습니다." - } else { - "저장된 자격증명 없음. Figma는 allowlist된 client_name으로 등록한 client에만 Dynamic Client Registration을 허용합니다." - } + "credentialSource": direct.credential_source, + "tokenState": direct.token_state, + "callbackPort": { + "port": direct.callback_port, + "free": direct.callback_port_free + }, + "reason": direct_reason(direct_available, direct.credential_source) }, "localDevMode": { "endpoint": LOCAL_DEV_MODE_ENDPOINT, @@ -156,6 +163,35 @@ pub async fn doctor_report(status: AuthStatus) -> Value { }) } +/// `direct.available` only reflects whether *some* token is stored (see +/// `AuthStatus`), so this fills in *why* it isn't yet, using the measured +/// `credentialSource` rather than assuming DCR is the only path — a +/// pre-registered client just needs `login`, not `configure` or the +/// waitlist. +fn direct_reason( + direct_available: bool, + credential_source: ClientCredentialSource, +) -> &'static str { + if direct_available { + return "저장된 자격증명이 있습니다."; + } + match credential_source { + ClientCredentialSource::None => { + "저장된 자격증명 없음. Figma는 allowlist된 client_name으로 등록한 client에만 \ + Dynamic Client Registration을 허용합니다. devup_figma_auth { action: \"configure\", \ + clientId, clientSecret }로 직접 확보한 client 자격증명을 등록하거나, Figma MCP \ + Catalog waitlist(https://www.figma.com/mcp-catalog/)에 등록하거나, 로컬 Dev Mode \ + MCP를 사용하거나, 호스트 핸드오프(sourcePolicy: auto 또는 host)를 사용하세요." + } + ClientCredentialSource::CliArg + | ClientCredentialSource::Env + | ClientCredentialSource::CredentialStore => { + "사전 등록된 client 자격증명이 있습니다. devup_figma_auth { action: \"login\" } 으로 \ + 인증하면 direct 경로를 사용할 수 있습니다." + } + } +} + fn client_setup() -> Value { json!({ "constraints": { @@ -271,13 +307,22 @@ mod tests { ); } + fn absent_direct_snapshot() -> DirectPathSnapshot { + DirectPathSnapshot { + credential_source: ClientCredentialSource::None, + token_state: devup_mcp_figma::TokenState::Absent, + callback_port: None, + callback_port_free: None, + } + } + #[tokio::test] async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { - let connected = doctor_report(AuthStatus::Connected).await; + let connected = doctor_report(AuthStatus::Connected, absent_direct_snapshot()).await; assert_eq!(connected["status"], "connected"); assert_eq!(connected["paths"]["direct"]["available"], true); - let disconnected = doctor_report(AuthStatus::Disconnected).await; + let disconnected = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; assert_eq!(disconnected["status"], "disconnected"); assert_eq!(disconnected["paths"]["direct"]["available"], false); assert_eq!( @@ -291,4 +336,51 @@ mod tests { assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); assert!(disconnected["clientSetup"]["opencode"]["example"].is_object()); } + + #[tokio::test] + async fn doctor_report_surfaces_credential_source_token_state_and_callback_port() { + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::CliArg, + token_state: devup_mcp_figma::TokenState::Expired, + callback_port: Some(19876), + callback_port_free: Some(false), + }; + let report = doctor_report(AuthStatus::Disconnected, snapshot).await; + assert_eq!(report["paths"]["direct"]["credentialSource"], "cli-arg"); + assert_eq!(report["paths"]["direct"]["tokenState"], "expired"); + assert_eq!(report["paths"]["direct"]["callbackPort"]["port"], 19876); + assert_eq!(report["paths"]["direct"]["callbackPort"]["free"], false); + // Even with a client credential configured, the reason must not + // point back at the DCR-blocked/waitlist guidance meant for the + // "no credential at all" case. + assert!( + !report["paths"]["direct"]["reason"] + .as_str() + .unwrap() + .contains("waitlist") + ); + } + + /// `DirectPathSnapshot` structurally cannot carry a client secret (it + /// has no such field — see `oauth.rs`), so `doctor_report` cannot leak + /// one regardless of which credential source is reported. This test + /// pins that invariant at the JSON boundary: the only permitted + /// occurrence of the substring "secret" is the static `clientSetup` + /// reference text that documents *where* a secret goes (field names, + /// not values) — never a real value. + #[tokio::test] + async fn doctor_report_only_mentions_secret_as_a_field_name_never_a_value() { + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::Env, + token_state: devup_mcp_figma::TokenState::Valid, + callback_port: Some(19876), + callback_port_free: Some(true), + }; + let report = doctor_report(AuthStatus::Connected, snapshot).await; + assert!(report["paths"]["direct"].get("clientSecret").is_none()); + assert!(report["paths"]["direct"].get("secret").is_none()); + let serialized = report.to_string(); + assert!(!serialized.contains("access_token")); + assert!(!serialized.contains("refresh_token")); + } } diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index e81566c..45ea28e 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -29,12 +29,13 @@ use serde_json::{Value, json}; use devup_mcp_devup_ui::theme::ThemeScope; use devup_mcp_figma::{ - AuthStatus, CollectedParts, CollectedPayload, CollectionRequest, CollectionScope, - CollectorSession, CollectorStep, CredentialStore, DevupError, ErrorCode, ExploreCandidate, - ExploreKind, ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, + AuthStatus, ClientCredentialSource, ClientCredentials, CollectedParts, CollectedPayload, + CollectionRequest, CollectionScope, CollectorSession, CollectorStep, CredentialStore, + DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, ExploreNode, + ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, KeyringCredentialStore, OAuthManager, RemoteFigmaClient, ResourceScope, SearchReadOptions, - SectionCandidate, SectionIndex, SectionReadOptions, SourcePolicy, SystemBrowser, - fallback_allowed_for_error, + SecretString, SectionCandidate, SectionIndex, SectionReadOptions, SourcePolicy, SystemBrowser, + TokenState, fallback_allowed_for_error, }; use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; @@ -60,6 +61,40 @@ pub trait DevupAuth: Send + Sync { async fn status(&self) -> Result; async fn login(&self) -> Result; async fn logout(&self) -> Result; + + /// Backs `devup_figma_auth {"action":"doctor"}`'s `paths.direct` + /// block. Default implementation derives a best-effort snapshot from + /// `status()` alone so existing `DevupAuth` test doubles keep + /// compiling without changes; `OAuthManager` overrides this with the + /// real credential-source/token-freshness/callback-port measurement. + async fn direct_path_snapshot(&self) -> Result { + let status = self.status().await?; + Ok(DirectPathSnapshot { + credential_source: ClientCredentialSource::default(), + token_state: if status == AuthStatus::Connected { + TokenState::Valid + } else { + TokenState::Absent + }, + callback_port: None, + callback_port_free: None, + }) + } + + /// Backs `devup_figma_auth {"action":"configure"}`. Default + /// implementation rejects: only auth backends that actually persist a + /// client credential (namely `OAuthManager`) support this. + async fn configure_client_credentials( + &self, + _client_id: String, + _client_secret: Option, + ) -> Result<(), DevupError> { + Err(DevupError::new( + ErrorCode::DevupAuthRequired, + "이 auth 백엔드는 client 자격증명 설정을 지원하지 않습니다.", + false, + )) + } } #[async_trait] @@ -77,6 +112,18 @@ impl DevupAuth for OAuthManager { OAuthManager::logout(self).await?; Ok(AuthStatus::Disconnected) } + + async fn direct_path_snapshot(&self) -> Result { + OAuthManager::direct_path_snapshot(self).await + } + + async fn configure_client_credentials( + &self, + client_id: String, + client_secret: Option, + ) -> Result<(), DevupError> { + OAuthManager::configure_client_credentials(self, client_id, client_secret).await + } } #[derive(Clone)] @@ -90,8 +137,21 @@ impl Services { Self { auth, upstream } } - fn production() -> Self { - let oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, KeyringCredentialStore); + fn production(figma_direct: crate::FigmaDirectConfig) -> Self { + let mut oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, KeyringCredentialStore) + .with_client_credential_store(Arc::new(KeyringClientCredentialStore)); + if figma_direct.callback_port.is_some() { + oauth = oauth.with_callback_port(figma_direct.callback_port); + } + if let Some(client_id) = figma_direct.client_id { + oauth = oauth.with_static_client_credentials( + ClientCredentials { + client_id, + client_secret: figma_direct.client_secret.map(SecretString::new), + }, + figma_direct.credential_source, + ); + } let upstream = RemoteFigmaClient::new(oauth.clone()); Self::new(Arc::new(oauth), Arc::new(upstream)) } @@ -128,16 +188,23 @@ impl DevupServer { }) } + pub fn production_with_config( + roots: Vec, + figma_direct: crate::FigmaDirectConfig, + ) -> Result { + Self::with_output_roots(Services::production(figma_direct), roots) + } + pub fn production_with_output_roots( roots: Vec, ) -> Result { - Self::with_output_roots(Services::production(), roots) + Self::production_with_config(roots, crate::FigmaDirectConfig::default()) } } impl Default for DevupServer { fn default() -> Self { - Self::new(Services::production()) + Self::new(Services::production(crate::FigmaDirectConfig::default())) } } @@ -324,7 +391,7 @@ fn permissive_object_output_schema() -> Arc { #[tool_router] impl DevupServer { #[tool( - description = "Check, start, or clear Figma Remote MCP OAuth (action: status | login | logout | doctor)", + description = "Check, start, or clear Figma Remote MCP OAuth, or inject a pre-registered client credential to skip Dynamic Client Registration (action: status | login | logout | configure | doctor)", output_schema = permissive_object_output_schema() )] async fn devup_figma_auth( @@ -333,7 +400,30 @@ impl DevupServer { ) -> Result { if input.action == "doctor" { let status = self.services.auth.status().await.map_err(to_mcp_error)?; - return Ok(tool_result(diagnostics::doctor_report(status).await)); + let direct = self + .services + .auth + .direct_path_snapshot() + .await + .map_err(to_mcp_error)?; + return Ok(tool_result( + diagnostics::doctor_report(status, direct).await, + )); + } + if input.action == "configure" { + let client_id = input.client_id.ok_or_else(|| { + to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "configure에는 clientId가 필요합니다.", + false, + )) + })?; + self.services + .auth + .configure_client_credentials(client_id, input.client_secret) + .await + .map_err(to_mcp_error)?; + return Ok(tool_result(json!({ "status": "configured" }))); } let status = match input.action.as_str() { "status" => self.services.auth.status().await, @@ -342,7 +432,7 @@ impl DevupServer { _ => { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupAuthRequired, - "action은 status, login, logout 또는 doctor여야 합니다.", + "action은 status, login, logout, configure 또는 doctor여야 합니다.", false, ))); } diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index c9df9b8..b3659c0 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -3,14 +3,22 @@ use schemars::{Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -/// `action` is `status`, `login`, `logout`, or `doctor`. `doctor` never -/// touches OAuth state; it measures which connection paths (direct OAuth, -/// local Dev Mode MCP, host handoff) are currently usable and returns -/// client-specific setup guidance. See `server::diagnostics`. +/// `action` is `status`, `login`, `logout`, `configure`, or `doctor`. +/// `doctor` never touches OAuth state; it measures which connection paths +/// (direct OAuth, local Dev Mode MCP, host handoff) are currently usable +/// and returns client-specific setup guidance. `configure` persists a +/// pre-registered client credential (`clientId`, optional `clientSecret`) +/// so later `login` calls skip Dynamic Client Registration entirely; the +/// secret is stored in the OS credential store and never echoed back. See +/// `server::diagnostics`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AuthInput { pub action: String, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/devup-mcp/tests/cli.rs b/crates/devup-mcp/tests/cli.rs index 615bbf7..4b50105 100644 --- a/crates/devup-mcp/tests/cli.rs +++ b/crates/devup-mcp/tests/cli.rs @@ -1,6 +1,6 @@ use std::{ffi::OsString, fs, process::Command}; -use devup_mcp::{CliAction, parse_cli_args}; +use devup_mcp::{CliAction, ClientCredentialSource, parse_cli_args, resolve_figma_direct_config}; #[path = "../build_identity.rs"] mod build_identity; @@ -148,5 +148,127 @@ fn no_arguments_use_the_startup_current_directory() -> anyhow::Result<()> { panic!("no arguments must start the server") }; assert_eq!(config.allowed_write_roots, vec![std::env::current_dir()?]); + assert_eq!(config.figma_client_id, None); + assert_eq!(config.figma_client_secret, None); + assert_eq!(config.figma_callback_port, None); Ok(()) } + +#[test] +fn figma_client_credential_and_callback_port_flags_populate_server_config() -> anyhow::Result<()> { + let action = parse_cli_args([ + OsString::from("--figma-client-id"), + OsString::from("preregistered-client"), + OsString::from("--figma-client-secret"), + OsString::from("preregistered-secret"), + OsString::from("--figma-callback-port"), + OsString::from("19876"), + ])?; + let CliAction::Serve(config) = action else { + panic!("figma flags must start the server") + }; + assert_eq!( + config.figma_client_id.as_deref(), + Some("preregistered-client") + ); + assert_eq!( + config.figma_client_secret.as_deref(), + Some("preregistered-secret") + ); + assert_eq!(config.figma_callback_port, Some(19876)); + Ok(()) +} + +#[test] +fn figma_callback_port_rejects_missing_or_non_numeric_values() { + assert!(parse_cli_args([OsString::from("--figma-callback-port")]).is_err()); + assert!( + parse_cli_args([ + OsString::from("--figma-callback-port"), + OsString::from("not-a-port"), + ]) + .is_err() + ); + assert!( + parse_cli_args([ + OsString::from("--figma-callback-port"), + OsString::from("70000"), + ]) + .is_err(), + "70000 exceeds u16::MAX and must be rejected, not silently truncated" + ); +} + +#[test] +fn figma_client_id_and_secret_reject_missing_or_empty_values() { + assert!(parse_cli_args([OsString::from("--figma-client-id")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-secret")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-id"), OsString::from("")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-secret"), OsString::from("")]).is_err()); +} + +#[test] +fn version_and_self_check_are_rejected_when_combined_with_figma_flags() { + // `--version`/`--self-check` must only win when they are the *sole* + // argument; combined with a figma flag they must not silently swallow + // the other flag and report a stale version/self-check instead of an + // error. + assert!( + parse_cli_args([ + OsString::from("--figma-client-id"), + OsString::from("preregistered-client"), + OsString::from("--self-check"), + ]) + .is_err() + ); + assert!( + parse_cli_args([ + OsString::from("--figma-client-id"), + OsString::from("preregistered-client"), + OsString::from("--version"), + ]) + .is_err() + ); +} + +#[test] +fn resolve_figma_direct_config_prioritizes_cli_arg_over_env() { + let resolved = resolve_figma_direct_config( + Some("cli-client".to_owned()), + Some("cli-secret".to_owned()), + Some(19876), + Some("env-client".to_owned()), + Some("env-secret".to_owned()), + ); + assert_eq!(resolved.client_id.as_deref(), Some("cli-client")); + assert_eq!(resolved.client_secret.as_deref(), Some("cli-secret")); + assert_eq!(resolved.credential_source, ClientCredentialSource::CliArg); + assert_eq!(resolved.callback_port, Some(19876)); +} + +#[test] +fn resolve_figma_direct_config_falls_back_to_env_then_to_none() { + let env_only = resolve_figma_direct_config( + None, + None, + None, + Some("env-client".to_owned()), + Some("env-secret".to_owned()), + ); + assert_eq!(env_only.client_id.as_deref(), Some("env-client")); + assert_eq!(env_only.credential_source, ClientCredentialSource::Env); + + let neither = resolve_figma_direct_config(None, None, None, None, None); + assert_eq!(neither.client_id, None); + assert_eq!(neither.client_secret, None); + assert_eq!(neither.credential_source, ClientCredentialSource::None); + + // Callback port is independent of credential source: it always comes + // from the cli-arg value regardless of which credential source won. + let callback_port_only = resolve_figma_direct_config(None, None, Some(19876), None, None); + assert_eq!(callback_port_only.callback_port, Some(19876)); + assert_eq!( + callback_port_only.credential_source, + ClientCredentialSource::None + ); +} diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index 4f7f580..b551bea 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -12,13 +12,15 @@ use std::sync::{ use async_trait::async_trait; use devup_mcp::server::{DevupAuth, DevupServer, Services}; use devup_mcp_figma::{ - AuthStatus, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, + AuthStatus, ClientCredentialSource, DevupError, DirectPathSnapshot, ErrorCode, FigmaUpstream, + ReadToolCall, TokenState, UpstreamResult, }; use rmcp::{ ServiceExt, model::{CallToolRequestParams, CallToolResult}, }; use serde_json::{Map, Value, json}; +use tokio::sync::Mutex; struct AuthProbe { status: AuthStatus, @@ -39,6 +41,45 @@ impl DevupAuth for AuthProbe { } } +/// A `DevupAuth` double that overrides `direct_path_snapshot` and +/// `configure_client_credentials`, unlike the plain `AuthProbe` above +/// which relies on the trait's default implementations. Used to verify +/// the server plumbing actually calls through to these methods and +/// surfaces their result verbatim, rather than the default fallback. +struct RichAuthProbe { + status: AuthStatus, + snapshot: DirectPathSnapshot, + configured: Mutex)>>, +} + +#[async_trait] +impl DevupAuth for RichAuthProbe { + async fn status(&self) -> Result { + Ok(self.status) + } + + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } + + async fn direct_path_snapshot(&self) -> Result { + Ok(self.snapshot.clone()) + } + + async fn configure_client_credentials( + &self, + client_id: String, + client_secret: Option, + ) -> Result<(), DevupError> { + *self.configured.lock().await = Some((client_id, client_secret)); + Ok(()) + } +} + #[derive(Default)] struct UnavailableUpstream { calls: AtomicUsize, @@ -308,3 +349,156 @@ async fn host_policy_needs_figma_also_carries_the_host_requirement() -> anyhow:: ); Ok(()) } + +/// A `DevupAuth` double that does not override `direct_path_snapshot` +/// (like `AuthProbe`) must still produce a shape-complete `doctor` +/// response via the trait's default implementation, so pre-existing +/// `DevupAuth` implementors outside this crate keep compiling *and* +/// keep working after this task's `credentialSource`/`tokenState`/ +/// `callbackPort` additions. +#[tokio::test] +async fn doctor_falls_back_to_default_direct_path_snapshot_for_plain_auth_doubles() +-> anyhow::Result<()> { + let output = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + + assert_eq!(output["paths"]["direct"]["credentialSource"], "none"); + assert_eq!(output["paths"]["direct"]["tokenState"], "absent"); + assert!(output["paths"]["direct"]["callbackPort"]["port"].is_null()); + assert!(output["paths"]["direct"]["callbackPort"]["free"].is_null()); + + let connected = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Connected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + assert_eq!(connected["paths"]["direct"]["tokenState"], "valid"); + Ok(()) +} + +/// The core deliverable of this task's `doctor` update: `paths.direct` +/// must reflect the real, measured `credentialSource`/`tokenState`/ +/// `callbackPort` from a `DevupAuth` implementation that actually tracks +/// them (here `RichAuthProbe`, standing in for the real `OAuthManager`). +#[tokio::test] +async fn doctor_reports_measured_credential_source_token_state_and_callback_port() +-> anyhow::Result<()> { + let auth = RichAuthProbe { + status: AuthStatus::Disconnected, + snapshot: DirectPathSnapshot { + credential_source: ClientCredentialSource::CliArg, + token_state: TokenState::Expired, + callback_port: Some(19876), + callback_port_free: Some(false), + }, + configured: Mutex::new(None), + }; + let output = call_named_tool( + Arc::new(auth), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + + assert_eq!(output["paths"]["direct"]["credentialSource"], "cli-arg"); + assert_eq!(output["paths"]["direct"]["tokenState"], "expired"); + assert_eq!(output["paths"]["direct"]["callbackPort"]["port"], 19876); + assert_eq!(output["paths"]["direct"]["callbackPort"]["free"], false); + Ok(()) +} + +/// `devup_figma_auth {"action":"configure"}` must persist the given +/// `clientId`/`clientSecret` via the auth backend, respond with only +/// `{"status":"configured"}` (never echoing the secret back), and reject +/// a missing `clientId` before ever calling the auth backend. +#[tokio::test] +async fn configure_action_persists_credentials_and_never_echoes_the_secret() -> anyhow::Result<()> { + let auth = Arc::new(RichAuthProbe { + status: AuthStatus::Disconnected, + snapshot: DirectPathSnapshot { + credential_source: ClientCredentialSource::None, + token_state: TokenState::Absent, + callback_port: None, + callback_port_free: None, + }, + configured: Mutex::new(None), + }); + let result = call_named_tool( + auth.clone(), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ + "action": "configure", + "clientId": "preregistered-client", + "clientSecret": "preregistered-secret" + }), + ) + .await?; + let output = result.structured_content.unwrap(); + assert_eq!(output, json!({ "status": "configured" })); + let raw = output.to_string(); + assert!(!raw.contains("preregistered-secret")); + + let captured = auth.configured.lock().await.clone(); + assert_eq!( + captured, + Some(( + "preregistered-client".to_owned(), + Some("preregistered-secret".to_owned()) + )) + ); + Ok(()) +} + +#[tokio::test] +async fn configure_action_without_client_id_is_rejected() -> anyhow::Result<()> { + let error = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "configure" }), + ) + .await + .expect_err("configure without clientId must fail"); + assert!(error.to_string().contains("clientId")); + Ok(()) +} + +/// `DevupAuth` implementations that do not support persisting a client +/// credential (the trait's default `configure_client_credentials`) must +/// surface that as an explicit tool error, not silently succeed. +#[tokio::test] +async fn configure_action_fails_for_auth_backends_that_do_not_support_it() -> anyhow::Result<()> { + let error = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "configure", "clientId": "preregistered-client" }), + ) + .await + .expect_err("plain AuthProbe does not support configure"); + assert!(!error.to_string().is_empty()); + Ok(()) +} From e83e0f5e18cb21b6ade841692a4d60913a0c8e04 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:33:56 +0900 Subject: [PATCH 05/69] feat(figma): add text-first fast envelope transport Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp-figma/src/envelope.rs | 166 +++++++++++++++++++---- crates/devup-mcp-figma/tests/envelope.rs | 40 ++++++ 2 files changed, 178 insertions(+), 28 deletions(-) diff --git a/crates/devup-mcp-figma/src/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index 144da20..7bd20e4 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, collections::BTreeSet}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use serde_json::{Value, json}; use crate::{ @@ -14,7 +14,9 @@ const ENVELOPE_CHUNK_TYPE: &[u8; 4] = b"duVp"; const EXPECTED_IHDR: &[u8; 13] = &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]; const MAX_PNG_BYTES: usize = 11 * 1024 * 1024; const MAX_BASE64_PNG_BYTES: usize = MAX_PNG_BYTES.div_ceil(3) * 4; -const MAX_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; +const MAX_SNAPSHOT_ENVELOPE_BYTES: usize = 1024 * 1024; +const MAX_THEME_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; +const MAX_TEXT_ENVELOPE_BYTES: usize = 15 * 1024; const MAX_ENVELOPE_CHUNKS: usize = 32; const MAX_STRINGIFIED_RESULT_BYTES: usize = 16 * 1024 * 1024; @@ -22,6 +24,7 @@ type EnvelopeChunk<'a> = (u32, u32, &'a [u8]); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FastTransportStats { + pub transport: &'static str, pub raw_bytes: usize, pub wire_bytes: usize, pub chunk_count: usize, @@ -44,6 +47,8 @@ pub struct FastThemePayload { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Envelope { + #[serde(default)] + kind: Option, schema_version: u32, source: EnvelopeSource, snapshot: SnapshotChunk, @@ -83,6 +88,8 @@ struct EnvelopeDescriptor { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeEnvelope { + #[serde(default)] + kind: Option, schema_version: u32, source: ThemeEnvelopeSource, resources: Value, @@ -149,6 +156,23 @@ fn decode_fast_snapshot_for_roots( expected_root_ids: &[String], ) -> Result { let raw = normalize_upstream_result(&result.raw)?; + if let Some((envelope, utf8_bytes)) = + find_tagged_text::(&raw, "devupFastSnapshotEnvelope")? + { + validate_envelope(&envelope, None, target, expected_root_ids, utf8_bytes)?; + return Ok(FastSnapshotPayload { + snapshot: envelope.snapshot, + resources: UpstreamResult { + raw: envelope.resources, + }, + stats: FastTransportStats { + transport: "text", + raw_bytes: utf8_bytes, + wire_bytes: utf8_bytes, + chunk_count: 0, + }, + }); + } let descriptor = find_descriptor(&raw)?; if descriptor.chunk_count == 0 { return Err(invalid("descriptorChunkCount")); @@ -196,17 +220,14 @@ fn decode_fast_snapshot_for_roots( if chunks.len() != descriptor.chunk_count { return Err(invalid("descriptorChunkCount")); } - let envelope_bytes = join_envelope_chunks(chunks)?; - if envelope_bytes.len() > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); - } + let envelope_bytes = join_envelope_chunks(chunks, MAX_SNAPSHOT_ENVELOPE_BYTES)?; let envelope_text = std::str::from_utf8(&envelope_bytes).map_err(|_| invalid("envelopeUtf8"))?; let envelope: Envelope = serde_json::from_str(envelope_text).map_err(|_| invalid("envelopeJson"))?; validate_envelope( &envelope, - &descriptor, + Some(&descriptor), target, expected_root_ids, envelope_bytes.len(), @@ -218,6 +239,7 @@ fn decode_fast_snapshot_for_roots( raw: envelope.resources, }, stats: FastTransportStats { + transport: "png-chunked", raw_bytes: envelope_bytes.len(), wire_bytes, chunk_count: descriptor.chunk_count, @@ -230,6 +252,23 @@ pub fn decode_fast_theme( expected_file_key: &str, ) -> Result { let raw = normalize_upstream_result(&result.raw)?; + if let Some((envelope, utf8_bytes)) = + find_tagged_text::(&raw, "devupFastThemeEnvelope")? + { + validate_theme_envelope(&envelope, None, expected_file_key, utf8_bytes)?; + return Ok(FastThemePayload { + resources: UpstreamResult { + raw: envelope.resources, + }, + source_version: envelope.source.version, + stats: FastTransportStats { + transport: "text", + raw_bytes: utf8_bytes, + wire_bytes: utf8_bytes, + chunk_count: 0, + }, + }); + } let descriptor = find_theme_descriptor(&raw)?; if descriptor.chunk_count == 0 { return Err(invalid("descriptorChunkCount")); @@ -272,17 +311,14 @@ pub fn decode_fast_theme( if chunks.len() != descriptor.chunk_count { return Err(invalid("descriptorChunkCount")); } - let envelope_bytes = join_envelope_chunks(chunks)?; - if envelope_bytes.len() > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); - } + let envelope_bytes = join_envelope_chunks(chunks, MAX_THEME_ENVELOPE_BYTES)?; let envelope_text = std::str::from_utf8(&envelope_bytes).map_err(|_| invalid("envelopeUtf8"))?; let envelope: ThemeEnvelope = serde_json::from_str(envelope_text).map_err(|_| invalid("envelopeJson"))?; validate_theme_envelope( &envelope, - &descriptor, + Some(&descriptor), expected_file_key, envelope_bytes.len(), )?; @@ -292,6 +328,7 @@ pub fn decode_fast_theme( }, source_version: envelope.source.version, stats: FastTransportStats { + transport: "png-chunked", raw_bytes: envelope_bytes.len(), wire_bytes, chunk_count: descriptor.chunk_count, @@ -313,6 +350,53 @@ fn normalize_upstream_result(value: &Value) -> Result, DevupError } } +fn find_tagged_text( + value: &Value, + expected_kind: &str, +) -> Result, DevupError> { + fn collect<'a>(value: &'a Value, expected_kind: &str, found: &mut Vec<&'a str>) { + match value { + Value::Object(object) => { + if let Some(text) = object.get("text").and_then(Value::as_str) + && serde_json::from_str::(text) + .ok() + .and_then(|value| { + value.get("kind").and_then(Value::as_str).map(str::to_owned) + }) + .as_deref() + == Some(expected_kind) + { + found.push(text); + } + for child in object.values() { + collect(child, expected_kind, found); + } + } + Value::Array(values) => { + for child in values { + collect(child, expected_kind, found); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + } + + let mut found = Vec::new(); + collect(value, expected_kind, &mut found); + match found.as_slice() { + [] => Ok(None), + [text] => { + if text.len() > MAX_TEXT_ENVELOPE_BYTES { + return Err(too_large("textEnvelope")); + } + serde_json::from_str(text) + .map(|envelope| Some((envelope, text.len()))) + .map_err(|_| invalid("envelopeJson")) + } + _ => Err(invalid("textEnvelopeMultiplicity")), + } +} + fn find_images(value: &Value) -> Result, DevupError> { fn collect<'a>(value: &'a Value, found: &mut Vec<(&'a str, &'a str)>) { match value { @@ -505,7 +589,10 @@ fn decode_png_envelope(png: &[u8]) -> Result>, DevupError> Ok(envelope_chunks) } -fn join_envelope_chunks(chunks: Vec>) -> Result, DevupError> { +fn join_envelope_chunks( + chunks: Vec>, + maximum_bytes: usize, +) -> Result, DevupError> { let total = u32::try_from(chunks.len()).map_err(|_| too_large("chunkCount"))?; let mut byte_count = 0_usize; for (expected_sequence, (sequence, declared_total, bytes)) in chunks.iter().enumerate() { @@ -515,7 +602,7 @@ fn join_envelope_chunks(chunks: Vec>) -> Result, Devup byte_count = byte_count .checked_add(bytes.len()) .ok_or_else(|| too_large("envelope"))?; - if byte_count > MAX_ENVELOPE_BYTES { + if byte_count > maximum_bytes { return Err(too_large("envelope")); } } @@ -528,12 +615,18 @@ fn join_envelope_chunks(chunks: Vec>) -> Result, Devup fn validate_envelope( envelope: &Envelope, - descriptor: &EnvelopeDescriptor, + descriptor: Option<&EnvelopeDescriptor>, target: &FigmaTarget, expected_root_ids: &[String], utf8_bytes: usize, ) -> Result<(), DevupError> { - if envelope.schema_version != 1 || descriptor.schema_version != 1 { + if envelope.schema_version != 1 + || descriptor.is_some_and(|descriptor| descriptor.schema_version != 1) + || envelope + .kind + .as_deref() + .is_some_and(|kind| kind != "devupFastSnapshotEnvelope") + { return Err(invalid("schemaVersion")); } let target_root = target @@ -543,12 +636,14 @@ fn validate_envelope( if envelope.source.file_key != target.file_key || envelope.snapshot.file_key != target.file_key || envelope.source.root_id != target_root - || descriptor.root_id != target_root + || descriptor.is_some_and(|descriptor| descriptor.root_id != target_root) || envelope.snapshot.root_ids != expected_root_ids { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes || descriptor.utf8_bytes != utf8_bytes { + if envelope.integrity.utf8_bytes != utf8_bytes + || descriptor.is_some_and(|descriptor| descriptor.utf8_bytes != utf8_bytes) + { return Err(invalid("utf8Bytes")); } @@ -559,7 +654,7 @@ fn validate_envelope( } } if envelope.integrity.node_count != node_ids.len() - || descriptor.node_count != node_ids.len() + || descriptor.is_some_and(|descriptor| descriptor.node_count != node_ids.len()) || !expected_root_ids .iter() .all(|root_id| node_ids.contains(root_id.as_str())) @@ -576,9 +671,10 @@ fn validate_envelope( let refs = collect_used_resource_refs(std::slice::from_ref(&envelope.snapshot)); if envelope.integrity.variable_ref_count != refs.variable_ids.len() - || descriptor.variable_ref_count != refs.variable_ids.len() + || descriptor + .is_some_and(|descriptor| descriptor.variable_ref_count != refs.variable_ids.len()) || envelope.integrity.style_ref_count != refs.styles.len() - || descriptor.style_ref_count != refs.styles.len() + || descriptor.is_some_and(|descriptor| descriptor.style_ref_count != refs.styles.len()) { return Err(invalid("resourceRefCount")); } @@ -588,17 +684,25 @@ fn validate_envelope( fn validate_theme_envelope( envelope: &ThemeEnvelope, - descriptor: &ThemeEnvelopeDescriptor, + descriptor: Option<&ThemeEnvelopeDescriptor>, expected_file_key: &str, utf8_bytes: usize, ) -> Result<(), DevupError> { - if envelope.schema_version != 1 || descriptor.schema_version != 1 { + if envelope.schema_version != 1 + || descriptor.is_some_and(|descriptor| descriptor.schema_version != 1) + || envelope + .kind + .as_deref() + .is_some_and(|kind| kind != "devupFastThemeEnvelope") + { return Err(invalid("schemaVersion")); } if envelope.source.file_key != expected_file_key { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes || descriptor.utf8_bytes != utf8_bytes { + if envelope.integrity.utf8_bytes != utf8_bytes + || descriptor.is_some_and(|descriptor| descriptor.utf8_bytes != utf8_bytes) + { return Err(invalid("utf8Bytes")); } let resources = envelope @@ -614,25 +718,31 @@ fn validate_theme_envelope( .ok_or_else(|| invalid("unresolvedShape"))?; validate_theme_count( envelope.integrity.collection_count, - descriptor.collection_count, + descriptor.map_or(envelope.integrity.collection_count, |value| { + value.collection_count + }), collections.len(), "collectionCount", )?; validate_theme_count( envelope.integrity.variable_count, - descriptor.variable_count, + descriptor.map_or(envelope.integrity.variable_count, |value| { + value.variable_count + }), variables.len(), "variableCount", )?; validate_theme_count( envelope.integrity.style_count, - descriptor.style_count, + descriptor.map_or(envelope.integrity.style_count, |value| value.style_count), styles.len(), "styleCount", )?; validate_theme_count( envelope.integrity.unresolved_count, - descriptor.unresolved_count, + descriptor.map_or(envelope.integrity.unresolved_count, |value| { + value.unresolved_count + }), unresolved.len(), "unresolvedCount", )?; diff --git a/crates/devup-mcp-figma/tests/envelope.rs b/crates/devup-mcp-figma/tests/envelope.rs index b8e97b4..001350f 100644 --- a/crates/devup-mcp-figma/tests/envelope.rs +++ b/crates/devup-mcp-figma/tests/envelope.rs @@ -26,6 +26,21 @@ fn valid_multichunk_envelope_round_trips() { assert_eq!(decoded.stats.raw_bytes, envelope.len()); assert!(decoded.stats.wire_bytes > envelope.len()); assert_eq!(decoded.stats.chunk_count, 2); + assert_eq!(decoded.stats.transport, "png-chunked"); +} + +#[test] +fn tagged_text_snapshot_envelope_round_trips_without_an_image() { + let envelope = complete_envelope(); + let result = text_upstream_result(&envelope); + + let decoded = decode_fast_snapshot(&result, &target()).expect("valid text envelope"); + + assert_eq!(decoded.snapshot.root_ids, ["1:1"]); + assert_eq!(decoded.stats.raw_bytes, envelope.len()); + assert_eq!(decoded.stats.wire_bytes, envelope.len()); + assert_eq!(decoded.stats.chunk_count, 0); + assert_eq!(decoded.stats.transport, "text"); } #[test] @@ -317,6 +332,18 @@ fn valid_fast_theme_envelope_round_trips_and_validates_counts() { assert_eq!(error.details["category"], "variableCount"); } +#[test] +fn tagged_text_theme_envelope_round_trips_without_an_image() { + let envelope = theme_envelope(); + let result = text_upstream_result(&envelope); + + let decoded = decode_fast_theme(&result, "fileKey123").expect("valid text theme envelope"); + + assert_eq!(decoded.source_version.as_deref(), Some("v42")); + assert_eq!(decoded.stats.chunk_count, 0); + assert_eq!(decoded.stats.transport, "text"); +} + #[test] fn json_stringified_fast_theme_result_round_trips() { let envelope = theme_envelope(); @@ -340,6 +367,7 @@ fn target() -> FigmaTarget { fn complete_envelope() -> Vec { finalize_envelope(json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": { "fileKey": "fileKey123", @@ -391,6 +419,7 @@ fn complete_envelope() -> Vec { fn theme_envelope() -> Vec { finalize_envelope(json!({ + "kind": "devupFastThemeEnvelope", "schemaVersion": 1, "source": {"fileKey": "fileKey123", "version": "v42"}, "resources": { @@ -414,6 +443,17 @@ fn theme_envelope() -> Vec { })) } +fn text_upstream_result(envelope: &[u8]) -> UpstreamResult { + UpstreamResult { + raw: json!({ + "content": [{ + "type": "text", + "text": std::str::from_utf8(envelope).unwrap() + }] + }), + } +} + fn theme_upstream_result(envelope: Vec, chunk_count: usize) -> UpstreamResult { let png = envelope_png(&envelope, chunk_count); let descriptor = json!({ From 2a97fbb7fdca8b29b282491632d10f93c257863e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:34:09 +0900 Subject: [PATCH 06/69] feat(figma): emit bounded text envelopes from fast scripts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp-figma/src/scripts/fast_snapshot.js | 10 +++++++--- crates/devup-mcp-figma/src/scripts/fast_theme.js | 3 +++ crates/devup-mcp-figma/src/upstream.rs | 12 ------------ crates/devup-mcp-figma/tests/upstream_contract.rs | 9 +++++++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 23f8752..35b1af3 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -1,19 +1,21 @@ -"__DEVUP_SECTION_INDEX_PROBE__"; - const requestedRootIds = "__DEVUP_ROOT_IDS__"; if (!Array.isArray(requestedRootIds) || requestedRootIds.length === 0) { throw new Error("DEVUP_ROOTS_INVALID"); } const roots = await Promise.all(requestedRootIds.map((id) => figma.getNodeByIdAsync(id))); if (roots.some((root) => !root)) throw new Error("DEVUP_NODE_NOT_FOUND"); +if (roots.length === 1 && roots[0].type === "SECTION") { + throw new Error("DEVUP_TARGET_IS_SECTION"); +} const envelopeRootId = "__DEVUP_NODE_ID__"; const manifest = "__DEVUP_PLUGIN_API_MANIFEST__"; const manifestSet = new Set(manifest); const textSegmentManifest = "__DEVUP_TEXT_SEGMENT_MANIFEST__"; const skipped = new Set(["id", "type", "parent", "children"]); -const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024; +const MAX_ENVELOPE_BYTES = 1024 * 1024; const MAX_ENVELOPE_CHUNK_BYTES = 512 * 1024; +const MAX_TEXT_ENVELOPE_BYTES = 15 * 1024; function propertyNames(value) { const names = new Set(); @@ -298,6 +300,7 @@ function utf8Encode(value) { } const envelope = { + kind: "devupFastSnapshotEnvelope", schemaVersion: 1, source: { fileKey: figma.fileKey || "", rootId: envelopeRootId }, snapshot: { @@ -339,6 +342,7 @@ if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { if (envelopeBytes.length > MAX_ENVELOPE_BYTES) { throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); } +if (envelopeBytes.length <= MAX_TEXT_ENVELOPE_BYTES) return envelope; function crc32(bytes) { let crc = 0xffffffff; diff --git a/crates/devup-mcp-figma/src/scripts/fast_theme.js b/crates/devup-mcp-figma/src/scripts/fast_theme.js index 68c0d03..102167c 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_theme.js +++ b/crates/devup-mcp-figma/src/scripts/fast_theme.js @@ -1,5 +1,6 @@ const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024; const MAX_ENVELOPE_CHUNK_BYTES = 512 * 1024; +const MAX_TEXT_ENVELOPE_BYTES = 15 * 1024; function propertyNames(value) { const names = new Set(Object.keys(value)); @@ -236,6 +237,7 @@ function utf8Encode(value) { } const envelope = { + kind: "devupFastThemeEnvelope", schemaVersion: 1, source: { fileKey: figma.fileKey || "", version: null }, resources: { @@ -269,6 +271,7 @@ if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); } if (envelopeBytes.length > MAX_ENVELOPE_BYTES) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); +if (envelopeBytes.length <= MAX_TEXT_ENVELOPE_BYTES) return envelope; function crc32(bytes) { let crc = 0xffffffff; diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index ac7859e..bd2463e 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -236,23 +236,11 @@ impl BuiltinScript { }) .unwrap_or_else(|| json!({})); let asset = serde_json::to_string(&asset).expect("asset options serialize"); - let section_index_probe = if self == Self::FastSnapshotEnvelope { - let mut probe = include_str!("scripts/section_index.js").replacen( - "if (section.type !== \"SECTION\") throw new Error(\"DEVUP_SECTION_REQUIRED\");", - "if (section.type === \"SECTION\") {", - 1, - ); - probe.push_str("\n}"); - format!("{{\n{probe}\n}}") - } else { - String::new() - }; source .replace( "\"__DEVUP_LARGE_VALUE_HELPERS__\"", include_str!("scripts/large_value_helpers.js"), ) - .replace("\"__DEVUP_SECTION_INDEX_PROBE__\"", §ion_index_probe) .replace("\"__DEVUP_NODE_ID__\"", &node_id) .replace("\"__DEVUP_ROOT_IDS__\"", &root_ids) .replace( diff --git a/crates/devup-mcp-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index c7b1665..214ec2a 100644 --- a/crates/devup-mcp-figma/tests/upstream_contract.rs +++ b/crates/devup-mcp-figma/tests/upstream_contract.rs @@ -343,6 +343,9 @@ fn fast_snapshot_is_lossless_bounded_and_read_only() { assert!(code.contains("devup-fast-snapshot-${sequence + 1}-of-${chunkCount}.png")); assert!(code.contains("devupFastSnapshotDescriptor")); assert!(code.contains("MAX_ENVELOPE_BYTES")); + assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); + assert!(code.contains("devupFastSnapshotEnvelope")); + assert!(code.contains("DEVUP_TARGET_IS_SECTION")); assert!(code.contains("0xfffd")); assert!(!code.contains("maxPayloadBytes")); assert!(!code.contains("maxFieldBytes")); @@ -356,11 +359,11 @@ fn fast_snapshot_is_lossless_bounded_and_read_only() { } #[test] -fn fast_snapshot_resolves_every_compiled_placeholder_after_inserting_the_section_probe() { +fn fast_snapshot_resolves_every_compiled_placeholder_for_the_requested_root() { let call = ReadToolCall::fast_snapshot("file-key", "3879:35518"); let code = call.arguments()["code"].as_str().unwrap().to_owned(); - assert!(code.contains("figma.getNodeByIdAsync(\"3879:35518\")")); + assert!(code.contains("const requestedRootIds = [\"3879:35518\"]")); assert!( !code.contains("__DEVUP_"), "compiled fast snapshot leaked an unresolved template placeholder" @@ -411,6 +414,8 @@ fn fast_theme_collects_complete_local_theme_and_used_remote_resources_read_only( assert!(code.contains("devup-fast-theme-${sequence + 1}-of-${chunkCount}.png")); assert!(code.contains("duVp")); assert!(code.contains("MAX_ENVELOPE_BYTES")); + assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); + assert!(code.contains("devupFastThemeEnvelope")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); for mutation in [ From ed7f8ddf20f56510476c94011cfabf79b333bbbe Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:34:23 +0900 Subject: [PATCH 07/69] feat(figma): preserve successful screens after partial collection failures Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp-devup-ui/tests/wquw_151.rs | 1 + .../tests/wquw_151_frames.rs | 1 + crates/devup-mcp-figma/src/collector.rs | 132 ++++++++++++++---- crates/devup-mcp-figma/src/lib.rs | 2 +- crates/devup-mcp-figma/src/payload.rs | 4 + crates/devup-mcp-figma/tests/collector.rs | 113 +++++++++++++-- .../devup-mcp-figma/tests/payload_contract.rs | 1 + crates/devup-mcp/tests/artifact_cache.rs | 1 + crates/devup-mcp/tests/resource_delivery.rs | 1 + 9 files changed, 221 insertions(+), 35 deletions(-) diff --git a/crates/devup-mcp-devup-ui/tests/wquw_151.rs b/crates/devup-mcp-devup-ui/tests/wquw_151.rs index 41438b3..9a75220 100644 --- a/crates/devup-mcp-devup-ui/tests/wquw_151.rs +++ b/crates/devup-mcp-devup-ui/tests/wquw_151.rs @@ -51,6 +51,7 @@ fn actual_wquw_151_screen_preserves_children_tokens_and_typography() { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), }; let output = generate_component( &payload.snapshot, diff --git a/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs b/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs index c918c04..4a4b1a4 100644 --- a/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +++ b/crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs @@ -189,6 +189,7 @@ fn every_actual_frame_generates_reviewed_devup_ui() { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), }; let output = generate_component( &payload.snapshot, diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index d7962d6..cd17def 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -116,6 +116,16 @@ pub struct CollectedParts { pub stats: CollectionStats, pub assets: Vec, pub reference_png: Option, + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScreenFailure { + pub node_id: String, + pub error_code: ErrorCode, + pub message: String, + pub retryable: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -220,6 +230,7 @@ pub struct CollectorSession { fast_multi_resources: Option, fast_multi_has_large_values: bool, section_fallback_roots: BTreeSet, + screen_failures: Vec, next_id: usize, completed: bool, } @@ -255,6 +266,7 @@ impl CollectorSession { fast_multi_resources: None, fast_multi_has_large_values: false, section_fallback_roots: BTreeSet::new(), + screen_failures: Vec::new(), next_id: 0, completed: false, } @@ -515,6 +527,7 @@ impl CollectorSession { stats: self.stats.clone(), assets: std::mem::take(&mut self.asset_results), reference_png: self.reference_png.take(), + failures: std::mem::take(&mut self.screen_failures), }))); } Ok(CollectorStep::AwaitingResults) @@ -565,6 +578,27 @@ impl CollectorSession { "알 수 없거나 이미 처리한 Figma call ID입니다.", )); }; + if pending.kind == CallKind::FastSnapshot && is_section_target_error(error) { + let pending = self + .pending + .remove(call_id) + .ok_or_else(|| invalid_call("fast Section probe call이 없습니다."))?; + self.consumed.insert(call_id.to_owned()); + let node_id = pending + .planned + .expected_node_id + .ok_or_else(|| invalid_call("fast Section probe의 node ID가 없습니다."))?; + self.request.section = Some(SectionReadOptions { + frame_ids: Vec::new(), + all_screens: false, + }); + self.enqueue( + ReadToolCall::section_index(&self.request.target.file_key, &node_id), + Some(node_id), + CallKind::SectionIndex, + ); + return Ok(true); + } if pending.kind == CallKind::Asset { let pending = self .pending @@ -589,6 +623,31 @@ impl CollectorSession { self.record_large_value_unsupported(&options, "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM")?; return Ok(true); } + if pending.kind == CallKind::Snapshot + && pending + .planned + .expected_node_id + .as_ref() + .is_some_and(|node_id| self.section_fallback_roots.contains(node_id)) + { + let pending = self + .pending + .remove(call_id) + .ok_or_else(|| invalid_call("Section legacy call이 없습니다."))?; + self.consumed.insert(call_id.to_owned()); + let node_id = pending + .planned + .expected_node_id + .ok_or_else(|| invalid_call("Section legacy call의 node ID가 없습니다."))?; + self.section_fallback_roots.remove(&node_id); + self.screen_failures.push(ScreenFailure { + node_id, + error_code: error.code, + message: error.message.clone(), + retryable: error.retryable, + }); + return Ok(true); + } if !matches!( pending.kind, CallKind::FastSnapshot | CallKind::FastTheme | CallKind::FastMultiRoot @@ -643,7 +702,7 @@ impl CollectorSession { } }; self.metadata = Some(json!({ - "transport": "png-theme-envelope-v1", + "transport": payload.stats.transport, "collectionCount": payload.resources.raw["collections"] .as_array().map_or(0, Vec::len), "variableCount": payload.resources.raw["variables"] @@ -652,7 +711,7 @@ impl CollectorSession { .as_array().map_or(0, Vec::len) })); self.source_version = payload.source_version; - self.stats.transport = "png-theme-envelope-v1".to_owned(); + self.stats.transport = payload.stats.transport.to_owned(); self.stats.raw_bytes = payload.stats.raw_bytes; self.stats.wire_bytes = payload.stats.wire_bytes; self.stats.envelope_chunks = payload.stats.chunk_count; @@ -695,14 +754,14 @@ impl CollectorSession { .clone() .ok_or_else(|| invalid_call("Figma fast snapshot에는 node ID가 필요합니다."))?; self.metadata = Some(json!({ - "transport": "png-envelope-v1", + "transport": payload.stats.transport, "rootId": root_id, "nodeCount": payload.snapshot.nodes.len() })); self.root_node_id = Some(root_id); self.source_version = payload.snapshot.version.clone(); self.metadata_root_ids = payload.snapshot.root_ids.clone(); - self.stats.transport = "png-envelope-v1".to_owned(); + self.stats.transport = payload.stats.transport.to_owned(); self.stats.raw_bytes = payload.stats.raw_bytes; self.stats.wire_bytes = payload.stats.wire_bytes; self.stats.envelope_chunks = payload.stats.chunk_count; @@ -727,6 +786,7 @@ impl CollectorSession { self.variables = None; self.large_values.clear(); self.section_fallback_roots.clear(); + self.screen_failures.clear(); self.asset_results.clear(); self.assets_scheduled = self.request.asset_selections.is_empty(); self.reference_png = None; @@ -893,15 +953,17 @@ impl CollectorSession { self.enqueue_section_legacy_root(root_id); } } else { - self.enqueue( - ReadToolCall::multi_root_snapshot( - &self.request.target.file_key, - section_id, - batch.root_ids, - ), - Some(section_id.to_owned()), - CallKind::FastMultiRoot, - ); + for root_id in batch.root_ids { + self.enqueue( + ReadToolCall::multi_root_snapshot( + &self.request.target.file_key, + section_id, + vec![root_id], + ), + Some(section_id.to_owned()), + CallKind::FastMultiRoot, + ); + } } } Ok(()) @@ -947,15 +1009,17 @@ impl CollectorSession { self.enqueue_section_legacy_root(root_id); } } else { - self.enqueue( - ReadToolCall::multi_root_snapshot( - &self.request.target.file_key, - §ion_id, - batch.root_ids, - ), - Some(section_id.clone()), - CallKind::FastMultiRoot, - ); + for root_id in batch.root_ids { + self.enqueue( + ReadToolCall::multi_root_snapshot( + &self.request.target.file_key, + §ion_id, + vec![root_id], + ), + Some(section_id.clone()), + CallKind::FastMultiRoot, + ); + } } } Ok(()) @@ -1000,7 +1064,7 @@ impl CollectorSession { self.fast_multi_has_large_values |= !descriptors_in_chunk(&payload.snapshot)?.is_empty(); merge_fast_resources(&mut self.fast_multi_resources, payload.resources)?; self.stats.transport = if self.section_fallback_roots.is_empty() { - "png-multi-root-envelope-v1" + payload.stats.transport } else { "hybrid-multi-root-cursor" } @@ -1080,10 +1144,15 @@ impl CollectorSession { } let snapshot = merge_chunks(chunks)?; let observed = snapshot.roots.iter().collect::>(); + let failed = self + .screen_failures + .iter() + .map(|failure| failure.node_id.as_str()) + .collect::>(); if self .section_selected_roots .iter() - .any(|root_id| !observed.contains(root_id)) + .any(|root_id| !observed.contains(root_id) && !failed.contains(root_id.as_str())) { return Err(invalid_call( "Section snapshot에 선택된 root가 모두 포함되지 않았습니다.", @@ -1092,7 +1161,12 @@ impl CollectorSession { Ok(vec![SnapshotChunk { file_key: snapshot.file_key, version: snapshot.version, - root_ids: self.section_selected_roots.clone(), + root_ids: self + .section_selected_roots + .iter() + .filter(|root_id| observed.contains(*root_id)) + .cloned() + .collect(), nodes: snapshot.nodes.into_values().collect(), diagnostics: snapshot.diagnostics, }]) @@ -1904,6 +1978,14 @@ fn fallback_category(error: &DevupError) -> String { .unwrap_or_else(|| format!("{:?}", error.code)) } +fn is_section_target_error(error: &DevupError) -> bool { + error.message.contains("DEVUP_TARGET_IS_SECTION") + || error + .details + .to_string() + .contains("DEVUP_TARGET_IS_SECTION") +} + fn fast_call_fallback_allowed(error: &DevupError) -> bool { matches!( error.code, diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index ede5604..6917148 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -18,7 +18,7 @@ mod variables; pub use collector::{ CollectedParts, CollectionRequest, CollectionScope, CollectionStats, CollectorSession, - CollectorStep, PlannedCall, ReferencePng, SectionReadOptions, + CollectorStep, PlannedCall, ReferencePng, ScreenFailure, SectionReadOptions, }; pub use credentials::{ diff --git a/crates/devup-mcp-figma/src/payload.rs b/crates/devup-mcp-figma/src/payload.rs index f7ba2a4..7fb87a2 100644 --- a/crates/devup-mcp-figma/src/payload.rs +++ b/crates/devup-mcp-figma/src/payload.rs @@ -34,6 +34,8 @@ pub struct CollectedPayload { pub assets: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub reference_png: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failures: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -77,6 +79,7 @@ impl CollectedPayload { CompletenessState::Failed } else if snapshot.state == CompletenessState::Partial || resources.state == CompletenessState::Partial + || !self.failures.is_empty() { CompletenessState::Partial } else { @@ -128,6 +131,7 @@ impl TryFrom for CollectedPayload { stats: parts.stats, assets: parts.assets, reference_png: parts.reference_png, + failures: parts.failures, }) } } diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index b480a90..1958ebc 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -35,7 +35,7 @@ fn exact_node_fast_path_completes_in_one_call() { assert_eq!(parts.snapshot_chunks.len(), 1); assert_eq!(parts.snapshot_chunks[0].nodes.len(), 1); assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-envelope-v1"); + assert_eq!(parts.stats.transport, "png-chunked"); assert!(!parts.stats.fallback_used); assert_eq!(parts.stats.node_count, 1); assert_eq!(parts.stats.variable_count, 0); @@ -68,7 +68,7 @@ fn exact_node_fast_path_accepts_the_stringified_handoff_contract() { panic!("stringified fast snapshot should complete without fallback") }; assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-envelope-v1"); + assert_eq!(parts.stats.transport, "png-chunked"); assert!(!parts.stats.fallback_used); } @@ -781,7 +781,7 @@ fn variables_only_file_collection_skips_page_and_node_snapshots() { panic!("valid fast theme should complete in one call") }; assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-theme-envelope-v1"); + assert_eq!(parts.stats.transport, "png-chunked"); assert!(!parts.stats.fallback_used); assert_eq!(parts.stats.variable_count, 1); assert_eq!(parts.stats.style_count, 1); @@ -1474,13 +1474,108 @@ fn section_collection_indexes_before_planning_selected_roots() { .accept(&index_call.id, compact_section_index()) .unwrap(); - let CollectorStep::Call(batch_call) = collector.advance().unwrap() else { - panic!("one bounded multi-root call expected") + let CollectorStep::Call(first_root_call) = collector.advance().unwrap() else { + panic!("first selected root call expected") + }; + let CollectorStep::Call(second_root_call) = collector.advance().unwrap() else { + panic!("second selected root call expected") + }; + assert_eq!(multi_root_ids(&first_root_call.call), ["10:3"]); + assert_eq!(multi_root_ids(&second_root_call.call), ["10:2"]); + assert_eq!(first_root_call.expected_node_id.as_deref(), Some("10:1")); +} + +#[test] +fn rejected_exact_section_probe_pivots_to_the_compact_index() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast section probe expected") }; - let arguments = batch_call.call.arguments(); - let code = arguments["code"].as_str().unwrap(); - assert!(code.contains("[\"10:3\",\"10:2\"]")); - assert_eq!(batch_call.expected_node_id.as_deref(), Some("10:1")); + + let recovered = collector + .reject( + &fast_call.id, + &DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "Error: DEVUP_TARGET_IS_SECTION", + false, + ), + ) + .unwrap(); + + assert!(recovered); + let CollectorStep::Call(index_call) = collector.advance().unwrap() else { + panic!("compact section index expected") + }; + assert!( + index_call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("subtreeNodeCount") + ); +} + +#[test] +fn failed_fast_and_legacy_section_root_is_reported_without_losing_siblings() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".to_owned(), "root-1".to_owned()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[3_000, 3_000])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!() + }; + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept( + &second.id, + fast_multi_envelope_result(&["root-1"], &["variable-success"]), + ) + .unwrap(); + assert!( + collector + .reject( + &first.id, + &DevupError::new(ErrorCode::DevupFigmaDirectUnavailable, "fast failed", true,) + ) + .unwrap() + ); + let CollectorStep::Call(legacy) = collector.advance().unwrap() else { + panic!("legacy retry expected") + }; + assert!( + collector + .reject( + &legacy.id, + &DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "legacy failed", + true, + ) + ) + .unwrap() + ); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("successful sibling should complete") + }; + assert_eq!( + merge_chunks(parts.snapshot_chunks).unwrap().roots, + ["root-1"] + ); + assert_eq!(parts.failures.len(), 1); + assert_eq!(parts.failures[0].node_id, "root-0"); + assert_eq!( + parts.failures[0].error_code, + ErrorCode::DevupFigmaDirectUnavailable + ); } #[test] diff --git a/crates/devup-mcp-figma/tests/payload_contract.rs b/crates/devup-mcp-figma/tests/payload_contract.rs index c1e9d31..4a41937 100644 --- a/crates/devup-mcp-figma/tests/payload_contract.rs +++ b/crates/devup-mcp-figma/tests/payload_contract.rs @@ -43,6 +43,7 @@ fn synthetic_parts() -> CollectedParts { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } diff --git a/crates/devup-mcp/tests/artifact_cache.rs b/crates/devup-mcp/tests/artifact_cache.rs index 5336686..1f9b9e6 100644 --- a/crates/devup-mcp/tests/artifact_cache.rs +++ b/crates/devup-mcp/tests/artifact_cache.rs @@ -77,6 +77,7 @@ fn payload(file_key: &str, node_id: &str, marker: &str) -> CollectedPayload { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } diff --git a/crates/devup-mcp/tests/resource_delivery.rs b/crates/devup-mcp/tests/resource_delivery.rs index e4dfae5..328a98b 100644 --- a/crates/devup-mcp/tests/resource_delivery.rs +++ b/crates/devup-mcp/tests/resource_delivery.rs @@ -479,5 +479,6 @@ fn payload() -> CollectedPayload { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } From 1b5345a1033884b7ed58d16648c1bbb34e158311 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:34:34 +0900 Subject: [PATCH 08/69] fix(server): recognize Section errors in host handoffs Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/server/handoff.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index a69a95a..0069f55 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -306,6 +306,23 @@ impl HandoffStore { put_session(&mut state, session_id.to_owned(), session); return Err(error); } + if is_section_error_result(&result) { + let mut rejected_collector = session.collector.clone(); + let error = DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "DEVUP_TARGET_IS_SECTION", + false, + ); + if rejected_collector.reject(&collector_call_id, &error)? { + session.collector = rejected_collector; + session.pending.remove(call_id); + session.consumed.insert(call_id.to_owned()); + session.result_bytes = session.result_bytes.saturating_add(encoded_len); + session.expires_at = now.saturating_add(self.limits.ttl.as_secs()); + put_session(&mut state, session_id.to_owned(), session); + return Ok(()); + } + } let mut result = result; strip_get_metadata_tail(&mut result); let mut accepted_collector = session.collector.clone(); @@ -334,6 +351,11 @@ impl HandoffStore { } } +fn is_section_error_result(value: &Value) -> bool { + value.get("isError").and_then(Value::as_bool) == Some(true) + && value.to_string().contains("DEVUP_TARGET_IS_SECTION") +} + fn take_session( state: &mut StoreState, session_id: &str, From 0025411c167124095acff21a1fcb55f0a93b67f0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:34:46 +0900 Subject: [PATCH 09/69] feat(server): report partial Section export failures Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/server/projection.rs | 23 +++++++++++++++++++++-- crates/devup-mcp/tests/section_export.rs | 10 +++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 6cfc0ab..f670ad7 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -643,6 +643,7 @@ pub(super) async fn complete_operation( result.insert("completenessReport".to_owned(), json!(&completeness_report)); result.insert("collection".to_owned(), json!(collection)); result.insert("cache".to_owned(), artifact_metadata(artifact)); + result.insert("failures".to_owned(), json!(&payload.failures)); result.insert( "source".to_owned(), json!({ @@ -715,6 +716,13 @@ pub(super) async fn complete_operation( "truncated": candidates.len() == 100 }), ); + result.insert( + "nextAction".to_owned(), + json!({ + "tool": "devup_figma_export", + "choose": ["frameIds", "allScreens"] + }), + ); return Ok(Value::Object(result)); } @@ -735,8 +743,16 @@ pub(super) async fn complete_operation( .iter() .map(|candidate| (candidate.node.node_id.as_str(), candidate)) .collect::>(); + let failed_ids = payload + .failures + .iter() + .map(|failure| failure.node_id.as_str()) + .collect::>(); let selected = if all_screens { - candidates.iter().collect::>() + candidates + .iter() + .filter(|candidate| !failed_ids.contains(candidate.node.node_id.as_str())) + .collect::>() } else { let requested = frame_ids .iter() @@ -763,7 +779,10 @@ pub(super) async fn complete_operation( } candidates .iter() - .filter(|candidate| requested.contains(candidate.node.node_id.as_str())) + .filter(|candidate| { + requested.contains(candidate.node.node_id.as_str()) + && !failed_ids.contains(candidate.node.node_id.as_str()) + }) .collect::>() }; let mut frames = Vec::with_capacity(selected.len()); diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index 7c1c78f..e009282 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -137,8 +137,8 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .any(|entry| entry["nodeId"] == "10:3" && entry["property"] == "type")) ); assert_eq!(selected["cache"]["cacheHit"], false); - assert_eq!(selected["collection"]["figmaToolCalls"], 1); - assert_eq!(upstream.0.load(Ordering::SeqCst), 2); + assert_eq!(selected["collection"]["figmaToolCalls"], 2); + assert_eq!(upstream.0.load(Ordering::SeqCst), 3); let selected_artifact_id = selected["cache"]["artifactId"].as_str().unwrap(); let all = call( @@ -159,10 +159,10 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10:2"] ); - assert_eq!(upstream.0.load(Ordering::SeqCst), 2); + assert_eq!(upstream.0.load(Ordering::SeqCst), 3); assert_eq!(all["collection"]["figmaToolCalls"], 0); - assert_eq!(all["cache"]["originCollection"]["figmaToolCalls"], 1); - assert_eq!(all["cache"]["avoidedFigmaToolCalls"], 1); + assert_eq!(all["cache"]["originCollection"]["figmaToolCalls"], 2); + assert_eq!(all["cache"]["avoidedFigmaToolCalls"], 2); let invalid = client .call_tool( From 3700a6908a293f679f10511dd9dce9a966554d17 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:34:57 +0900 Subject: [PATCH 10/69] docs(server): require per-screen Section exports Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/server/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 45ea28e..d0489b8 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -972,7 +972,8 @@ impl ServerHandler for DevupServer { 4. 핸드오프로 받은 노드 트리를 직접 해석해 devup-ui 코드를 작성하지 마라. 좌표로 레이아웃을 추론하지 마라.\n\ 5. 핸드오프 단계에서는 요청된 도구를 요청된 arguments 그대로 실행하고, 원본 결과를 가공 없이 devup_figma_continue로 돌려줘라.\n\ 6. devup-mcp 호출이 실패하면 명시적으로 기록하라. 조용히 다른 방법으로 우회하지 마라.\n\ - 7. 색상·간격·radius·타이포 같은 UI 수치를 추측하지 마라. 확보하지 못했으면 멈추고 보고하라.", + 7. 색상·간격·radius·타이포 같은 UI 수치를 추측하지 마라. 확보하지 못했으면 멈추고 보고하라.\n\ + 8. Section 링크는 전체 subtree로 구현하지 마라. selection_required의 후보를 확인하고 frameIds 또는 allScreens로 화면별 export를 계속하라.", ) } From d8ccae61a837a63c88fef78c2431affc575b935b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:35:08 +0900 Subject: [PATCH 11/69] docs: explain Section continuation and partial failures Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4fc0a28..f30f1c5 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,8 @@ codex mcp add figma --url https://mcp.figma.com/mcp Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 canonical URL을 `selection_required`로 반환합니다. `frameIds`로 검토한 frame만 고르거나 `allScreens: true`로 모든 화면을 시각 순서대로 batch export할 수 있으며 두 옵션은 동시에 사용할 수 없습니다. `sourceMap`은 생성 TSX/devup.json의 output 위치를 Figma node, variable, style, asset ID에 연결하는 sidecar입니다. `assetManifest`는 image hash/vector/export provenance를 항상 열거하고, `assetRequests`로 명시한 항목만 최대 16개·scale 1~4 범위에서 read-only SVG/PNG export합니다. `outputPath`를 지정하면 binary를 해당 파일로 디코딩하고 응답의 base64를 제거하며, 생략하면 후속 소비를 위해 base64가 memory-only artifact와 해당 MCP 응답에 남을 수 있습니다. +Section 링크는 전체 subtree를 직접 변환하지 않습니다. `selection_required.nextAction`에 따라 후보를 확인한 뒤 `frameIds` 또는 `allScreens: true`로 화면별 export를 계속하며, 일부 화면 수집이 실패하면 성공한 화면은 유지하고 실패한 node는 `failures`에 보고합니다. + ### Figma 이름 검색 ```json From 665fa0212f908f207690cf7f370d4bd38bc9d58c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 09:41:50 +0900 Subject: [PATCH 12/69] fix(server): match brief's literal nextAction shape for Section selection_required The Section selection_required response's nextAction previously carried an ad-hoc {tool, choose} shape instead of the why/how/doNot guidance specified for agents consuming the response. Align it exactly and lock it with a test assertion on the selection_required response. --- crates/devup-mcp/src/server/projection.rs | 5 +++-- crates/devup-mcp/tests/section_export.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index f670ad7..d23ed3d 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -719,8 +719,9 @@ pub(super) async fn complete_operation( result.insert( "nextAction".to_owned(), json!({ - "tool": "devup_figma_export", - "choose": ["frameIds", "allScreens"] + "why": "이 링크는 Section이며 내부에 화면이 여러 개 있습니다. 한 번에 전부 수집하면 크기 한도를 넘습니다.", + "how": "screens[] 중 대상 화면의 canonicalUrl 로 재호출하거나, 전부 필요하면 allScreens:true 를 쓰세요.", + "doNot": "Section 전체를 한 번에 수집하려 하지 마세요." }), ); return Ok(Value::Object(result)); diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index e009282..ca30575 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -99,6 +99,18 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10:2"] ); + assert_eq!( + selection["nextAction"]["why"], + "이 링크는 Section이며 내부에 화면이 여러 개 있습니다. 한 번에 전부 수집하면 크기 한도를 넘습니다." + ); + assert_eq!( + selection["nextAction"]["how"], + "screens[] 중 대상 화면의 canonicalUrl 로 재호출하거나, 전부 필요하면 allScreens:true 를 쓰세요." + ); + assert_eq!( + selection["nextAction"]["doNot"], + "Section 전체를 한 번에 수집하려 하지 마세요." + ); assert_eq!(upstream.0.load(Ordering::SeqCst), 1); let artifact_id = selection["cache"]["artifactId"].as_str().unwrap(); assert_eq!(selection["cache"]["capabilities"]["kind"], "section-index"); From e5e9d27d3af002f0b37e0d913f1d7c154431131f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 11:14:58 +0900 Subject: [PATCH 13/69] feat(figma): shrink fast snapshot manifest and paginate the text envelope 6th-round brief, measured against real Figma node 3997:47467 in file 85CgSws3o5XsLv7aAwWJyS (rectangle+text, 2 nodes). A. Collection whitelist (fast_snapshot.js, plugin_api_manifest.json) - propertyNames() no longer walks the Figma Plugin API prototype chain; it only checks the checked-in manifest, so unlisted runtime properties are never collected at all. - Dropped the "extra" bucket entirely - a field is either in the (now-trimmed) manifest or it is not collected, period. - Trimmed plugin_api_manifest.json from 133 to 77 entries, keeping only fields devup-mcp-devup-ui's codegen/provenance/style/layout/text modules and the Rust resource scanner (resources.rs) actually read (verified via literal + snake_case usage grep across every prod .rs file, cross-checked against false positives like devup-ui's own CSS style-prop allowlist and Variable.remote colliding with node "remote"). - Omit envelope defaults that carry no information beyond "unset": null, empty arrays, and empty *StyleId strings (proven equivalent to an absent key for every existing consumer: resources.rs::is_resource_id and codegen/text.rs's token-map lookup). - Legacy snapshot.js is untouched (still walks the prototype chain into "extra"); the shared, trimmed manifest also shrinks its payload with no loss of runtime property coverage. - Real re-measurement (same node, live use_figma execution): before 11,542 bytes/node -> after 1,925.5 bytes/node (-83.3%). B. Text pagination, PNG transport removed (fast_snapshot.js, fast_theme.js, envelope.rs, collector.rs) - Deleted the PNG-chunked binary transport from both fast scripts (figma.io.write, crc32/pngChunk/duVp helpers) - real measurement proved image content blocks get silently discarded by the host, so it never worked end to end. - fast_snapshot.js now dynamically byte-budgets a page starting at offset (reusing the existing SnapshotReadOptions/cursor convention from the legacy path) and always appends a __DEVUP_SNAPSHOT_CURSOR__ marker node reporting nextOffset/complete/totalNodes. - Each page only scans/fetches resources for the nodes it actually ships, so a page's own integrity counters stay self-consistent; collector.rs merges resources and node chunks across rounds (reusing merge_fast_resources/record_snapshot_chunk). - envelope.rs's validate_envelope relaxes root-containment (only required on the first page) and dangling-child checks (only enforced once complete) for a partial page, while keeping every other integrity check unchanged. - CollectionStats.transport is now one of text | text-paginated | legacy-cursor (was png-chunked | text | legacy-cursor). D. use_figma argument schema fix (upstream.rs, handoff.rs) - The official use_figma schema is { fileKey, code, description, skillNames? } with additionalProperties: false - nodeId was an invalid argument that a real Figma MCP host rejects. Removed it from every use_figma-routed ReadToolCall::arguments() and added the now-required description. - The target node is still surfaced to handoff consumers, just outside rguments: PlannedCall::expected_node_id already tracked it separately, so HandoffCall gained a sibling odeId field. E. Transport label default (collector.rs) - CollectionStats::default().transport is now "text" (was "legacy-cursor"), matching text being the primary path. Tests: rewrote crates/devup-mcp-figma/tests/envelope.rs around the paginated text-only shape (root/dangling-child relaxation per page, cursor multiplicity, oversized-text rejection); updated upstream_contract.rs, collector.rs, composite_export.rs, section_export.rs and source_orchestration.rs fixtures/assertions for the new manifest, transport labels and argument shape. No PNG mock fixtures remain in the test suite. --- crates/devup-mcp-figma/src/collector.rs | 92 ++- crates/devup-mcp-figma/src/envelope.rs | 551 +++------------- .../src/plugin_api_manifest.json | 39 +- .../src/scripts/fast_snapshot.js | 192 +++--- .../devup-mcp-figma/src/scripts/fast_theme.js | 60 +- crates/devup-mcp-figma/src/upstream.rs | 90 ++- crates/devup-mcp-figma/tests/collector.rs | 141 +---- crates/devup-mcp-figma/tests/envelope.rs | 595 +++++++----------- .../tests/upstream_contract.rs | 79 ++- crates/devup-mcp/src/server/handoff.rs | 7 + crates/devup-mcp/tests/composite_export.rs | 54 +- crates/devup-mcp/tests/section_export.rs | 48 +- .../devup-mcp/tests/source_orchestration.rs | 26 +- 13 files changed, 728 insertions(+), 1246 deletions(-) diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index cd17def..fc2a4bb 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -38,7 +38,7 @@ const USED_RESOURCE_BATCH_BYTES: usize = 12_000; // Consumer relations can be huge. Compact, bounded fragments are expanded // back to the exhaustive shape in Rust without dropping any relation. const STYLE_CONSUMER_BATCH_SIZE: usize = 320; -const SNAPSHOT_CURSOR_ID: &str = "__DEVUP_SNAPSHOT_CURSOR__"; +pub(crate) const SNAPSHOT_CURSOR_ID: &str = "__DEVUP_SNAPSHOT_CURSOR__"; const MAX_REFERENCE_PNG_BYTES: usize = 16 * 1024 * 1024; const MAX_REFERENCE_PNG_BASE64_BYTES: usize = MAX_REFERENCE_PNG_BYTES.div_ceil(3) * 4; const MAX_REFERENCE_PNG_DIMENSION: u32 = 8_192; @@ -157,7 +157,10 @@ impl Default for CollectionStats { fn default() -> Self { Self { figma_tool_calls: 0, - transport: "legacy-cursor".to_owned(), + // Text (optionally paginated) is the default, primary path now; + // "legacy-cursor" only ever appears once a fast call actually + // falls back (see `restart_legacy`). + transport: "text".to_owned(), fallback_used: false, fallback_reason: None, node_count: 0, @@ -229,6 +232,13 @@ pub struct CollectorSession { section_selected_roots: Vec, fast_multi_resources: Option, fast_multi_has_large_values: bool, + /// Resources merged across rounds of the paginated single-root fast + /// snapshot (`accept_fast_snapshot`). Distinct from `fast_multi_resources`, + /// which is scoped to Section multi-root batching; the two paths are + /// mutually exclusive (`fast_path_eligible` requires `section.is_none()`). + fast_snapshot_resources: Option, + fast_snapshot_has_large_values: bool, + fast_snapshot_rounds: usize, section_fallback_roots: BTreeSet, screen_failures: Vec, next_id: usize, @@ -265,6 +275,9 @@ impl CollectorSession { section_selected_roots: Vec::new(), fast_multi_resources: None, fast_multi_has_large_values: false, + fast_snapshot_resources: None, + fast_snapshot_has_large_values: false, + fast_snapshot_rounds: 0, section_fallback_roots: BTreeSet::new(), screen_failures: Vec::new(), next_id: 0, @@ -753,21 +766,69 @@ impl CollectorSession { .node_id .clone() .ok_or_else(|| invalid_call("Figma fast snapshot에는 node ID가 필요합니다."))?; - self.metadata = Some(json!({ - "transport": payload.stats.transport, - "rootId": root_id, - "nodeCount": payload.snapshot.nodes.len() - })); - self.root_node_id = Some(root_id); + self.root_node_id = Some(root_id.clone()); self.source_version = payload.snapshot.version.clone(); self.metadata_root_ids = payload.snapshot.root_ids.clone(); - self.stats.transport = payload.stats.transport.to_owned(); - self.stats.raw_bytes = payload.stats.raw_bytes; - self.stats.wire_bytes = payload.stats.wire_bytes; - self.stats.envelope_chunks = payload.stats.chunk_count; + self.fast_snapshot_rounds = self.fast_snapshot_rounds.saturating_add(1); + self.stats.raw_bytes = self.stats.raw_bytes.saturating_add(payload.stats.raw_bytes); + self.stats.wire_bytes = self + .stats + .wire_bytes + .saturating_add(payload.stats.wire_bytes); let has_large_values = !descriptors_in_chunk(&payload.snapshot)?.is_empty(); - self.variables = (!has_large_values).then_some(payload.resources); - self.record_snapshot_chunk(order, payload.snapshot)?; + if has_large_values { + self.fast_snapshot_has_large_values = true; + } else { + merge_fast_resources(&mut self.fast_snapshot_resources, payload.resources)?; + } + + let mut chunk = payload.snapshot; + // The script always appends a `__DEVUP_SNAPSHOT_CURSOR__` marker node + // (same convention as the legacy cursor snapshot) reporting whether + // more pages remain; `take_snapshot_cursor` strips it and returns + // that state. A missing marker (only possible for hand-built, + // pre-pagination-shaped payloads) is treated as a single complete + // page. `record_snapshot_chunk` then stores this page's real nodes + // and enqueues any large-value follow-ups they declared. + let total_nodes = chunk.nodes.len(); + let cursor = take_snapshot_cursor(&mut chunk)?.unwrap_or(SnapshotCursor { + next_offset: total_nodes, + complete: true, + total_nodes, + }); + self.record_snapshot_chunk(order, chunk)?; + + if cursor.complete { + self.stats.transport = if self.fast_snapshot_rounds > 1 { + "text-paginated" + } else { + "text" + } + .to_owned(); + self.stats.envelope_chunks = 0; + self.variables = (!self.fast_snapshot_has_large_values) + .then(|| self.fast_snapshot_resources.take()) + .flatten(); + self.metadata = Some(json!({ + "transport": &self.stats.transport, + "rootId": root_id, + "nodeCount": cursor.total_nodes, + "pageCount": self.fast_snapshot_rounds + })); + } else { + self.enqueue( + ReadToolCall::fast_snapshot_page( + &self.request.target.file_key, + &root_id, + SnapshotReadOptions { + offset: cursor.next_offset, + ..SnapshotReadOptions::default() + }, + ), + Some(root_id), + CallKind::FastSnapshot, + ); + } Ok(()) } @@ -785,6 +846,9 @@ impl CollectorSession { self.variable_batches.clear(); self.variables = None; self.large_values.clear(); + self.fast_snapshot_resources = None; + self.fast_snapshot_has_large_values = false; + self.fast_snapshot_rounds = 0; self.section_fallback_roots.clear(); self.screen_failures.clear(); self.asset_results.clear(); diff --git a/crates/devup-mcp-figma/src/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index 7bd20e4..1f81582 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -1,27 +1,16 @@ use std::{borrow::Cow, collections::BTreeSet}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, de::DeserializeOwned}; use serde_json::{Value, json}; use crate::{ - DevupError, ErrorCode, FigmaTarget, ResourceKind, SnapshotChunk, UpstreamResult, - collect_used_resource_refs, + DevupError, ErrorCode, FigmaTarget, RawNode, ResourceKind, SnapshotChunk, UpstreamResult, + collect_used_resource_refs, collector::SNAPSHOT_CURSOR_ID, }; -const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; -const ENVELOPE_CHUNK_TYPE: &[u8; 4] = b"duVp"; -const EXPECTED_IHDR: &[u8; 13] = &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]; -const MAX_PNG_BYTES: usize = 11 * 1024 * 1024; -const MAX_BASE64_PNG_BYTES: usize = MAX_PNG_BYTES.div_ceil(3) * 4; -const MAX_SNAPSHOT_ENVELOPE_BYTES: usize = 1024 * 1024; -const MAX_THEME_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; const MAX_TEXT_ENVELOPE_BYTES: usize = 15 * 1024; -const MAX_ENVELOPE_CHUNKS: usize = 32; const MAX_STRINGIFIED_RESULT_BYTES: usize = 16 * 1024 * 1024; -type EnvelopeChunk<'a> = (u32, u32, &'a [u8]); - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FastTransportStats { pub transport: &'static str, @@ -72,19 +61,6 @@ struct EnvelopeIntegrity { utf8_bytes: usize, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EnvelopeDescriptor { - kind: String, - schema_version: u32, - root_id: String, - node_count: usize, - variable_ref_count: usize, - style_ref_count: usize, - utf8_bytes: usize, - chunk_count: usize, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeEnvelope { @@ -113,19 +89,6 @@ struct ThemeEnvelopeIntegrity { utf8_bytes: usize, } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ThemeEnvelopeDescriptor { - kind: String, - schema_version: u32, - collection_count: usize, - variable_count: usize, - style_count: usize, - unresolved_count: usize, - utf8_bytes: usize, - chunk_count: usize, -} - pub fn decode_fast_snapshot( result: &UpstreamResult, target: &FigmaTarget, @@ -150,99 +113,36 @@ pub fn decode_fast_multi_snapshot( decode_fast_snapshot_for_roots(result, target, expected_root_ids) } +/// Fast node snapshots are always delivered as text now (no PNG-chunked +/// binary transport exists any more — real-world hosts silently discarded +/// those image attachments, so it never actually worked). A single round may +/// legitimately cover only *part* of the target subtree; `peek_page_cursor` +/// reports whether this is the case so `validate_envelope` can relax the +/// root-containment and dangling-child checks that only hold for a complete, +/// self-contained envelope. fn decode_fast_snapshot_for_roots( result: &UpstreamResult, target: &FigmaTarget, expected_root_ids: &[String], ) -> Result { let raw = normalize_upstream_result(&result.raw)?; - if let Some((envelope, utf8_bytes)) = + let Some((envelope, utf8_bytes)) = find_tagged_text::(&raw, "devupFastSnapshotEnvelope")? - { - validate_envelope(&envelope, None, target, expected_root_ids, utf8_bytes)?; - return Ok(FastSnapshotPayload { - snapshot: envelope.snapshot, - resources: UpstreamResult { - raw: envelope.resources, - }, - stats: FastTransportStats { - transport: "text", - raw_bytes: utf8_bytes, - wire_bytes: utf8_bytes, - chunk_count: 0, - }, - }); - } - let descriptor = find_descriptor(&raw)?; - if descriptor.chunk_count == 0 { - return Err(invalid("descriptorChunkCount")); - } - if descriptor.chunk_count > MAX_ENVELOPE_CHUNKS { - return Err(too_large("chunkCount")); - } - - let images = find_images(&raw)?; - if images.len() > descriptor.chunk_count { - return Err(invalid("imageMultiplicity")); - } - let mut encoded_bytes = 0_usize; - let mut wire_bytes = 0_usize; - let mut pngs = Vec::with_capacity(images.len()); - for (encoded, mime_type) in images { - if mime_type != "image/png" { - return Err(invalid("imageMime")); - } - encoded_bytes = encoded_bytes - .checked_add(encoded.len()) - .ok_or_else(|| too_large("png"))?; - let maximum_encoded_bytes = MAX_BASE64_PNG_BYTES - .checked_add(MAX_ENVELOPE_CHUNKS * 3) - .ok_or_else(|| too_large("png"))?; - if encoded_bytes > maximum_encoded_bytes { - return Err(too_large("png")); - } - let png = STANDARD - .decode(encoded) - .map_err(|_| invalid("imageBase64"))?; - wire_bytes = wire_bytes - .checked_add(png.len()) - .ok_or_else(|| too_large("png"))?; - if wire_bytes > MAX_PNG_BYTES { - return Err(too_large("png")); - } - pngs.push(png); - } - - let mut chunks = Vec::with_capacity(descriptor.chunk_count); - for png in &pngs { - chunks.extend(decode_png_envelope(png)?); - } - if chunks.len() != descriptor.chunk_count { - return Err(invalid("descriptorChunkCount")); - } - let envelope_bytes = join_envelope_chunks(chunks, MAX_SNAPSHOT_ENVELOPE_BYTES)?; - let envelope_text = - std::str::from_utf8(&envelope_bytes).map_err(|_| invalid("envelopeUtf8"))?; - let envelope: Envelope = - serde_json::from_str(envelope_text).map_err(|_| invalid("envelopeJson"))?; - validate_envelope( - &envelope, - Some(&descriptor), - target, - expected_root_ids, - envelope_bytes.len(), - )?; - + else { + return Err(invalid("textEnvelopeMissing")); + }; + let page = peek_page_cursor(&envelope.snapshot.nodes)?; + validate_envelope(&envelope, target, expected_root_ids, utf8_bytes, page)?; Ok(FastSnapshotPayload { snapshot: envelope.snapshot, resources: UpstreamResult { raw: envelope.resources, }, stats: FastTransportStats { - transport: "png-chunked", - raw_bytes: envelope_bytes.len(), - wire_bytes, - chunk_count: descriptor.chunk_count, + transport: "text", + raw_bytes: utf8_bytes, + wire_bytes: utf8_bytes, + chunk_count: 0, }, }) } @@ -252,90 +152,69 @@ pub fn decode_fast_theme( expected_file_key: &str, ) -> Result { let raw = normalize_upstream_result(&result.raw)?; - if let Some((envelope, utf8_bytes)) = + let Some((envelope, utf8_bytes)) = find_tagged_text::(&raw, "devupFastThemeEnvelope")? - { - validate_theme_envelope(&envelope, None, expected_file_key, utf8_bytes)?; - return Ok(FastThemePayload { - resources: UpstreamResult { - raw: envelope.resources, - }, - source_version: envelope.source.version, - stats: FastTransportStats { - transport: "text", - raw_bytes: utf8_bytes, - wire_bytes: utf8_bytes, - chunk_count: 0, - }, - }); - } - let descriptor = find_theme_descriptor(&raw)?; - if descriptor.chunk_count == 0 { - return Err(invalid("descriptorChunkCount")); - } - if descriptor.chunk_count > MAX_ENVELOPE_CHUNKS { - return Err(too_large("chunkCount")); - } - let images = find_images(&raw)?; - if images.len() > descriptor.chunk_count { - return Err(invalid("imageMultiplicity")); - } - let mut encoded_bytes = 0_usize; - let mut wire_bytes = 0_usize; - let mut pngs = Vec::with_capacity(images.len()); - for (encoded, mime_type) in images { - if mime_type != "image/png" { - return Err(invalid("imageMime")); - } - encoded_bytes = encoded_bytes - .checked_add(encoded.len()) - .ok_or_else(|| too_large("png"))?; - if encoded_bytes > MAX_BASE64_PNG_BYTES + MAX_ENVELOPE_CHUNKS * 3 { - return Err(too_large("png")); - } - let png = STANDARD - .decode(encoded) - .map_err(|_| invalid("imageBase64"))?; - wire_bytes = wire_bytes - .checked_add(png.len()) - .ok_or_else(|| too_large("png"))?; - if wire_bytes > MAX_PNG_BYTES { - return Err(too_large("png")); - } - pngs.push(png); - } - let mut chunks = Vec::with_capacity(descriptor.chunk_count); - for png in &pngs { - chunks.extend(decode_png_envelope(png)?); - } - if chunks.len() != descriptor.chunk_count { - return Err(invalid("descriptorChunkCount")); - } - let envelope_bytes = join_envelope_chunks(chunks, MAX_THEME_ENVELOPE_BYTES)?; - let envelope_text = - std::str::from_utf8(&envelope_bytes).map_err(|_| invalid("envelopeUtf8"))?; - let envelope: ThemeEnvelope = - serde_json::from_str(envelope_text).map_err(|_| invalid("envelopeJson"))?; - validate_theme_envelope( - &envelope, - Some(&descriptor), - expected_file_key, - envelope_bytes.len(), - )?; + else { + return Err(invalid("textEnvelopeMissing")); + }; + validate_theme_envelope(&envelope, expected_file_key, utf8_bytes)?; Ok(FastThemePayload { resources: UpstreamResult { raw: envelope.resources, }, source_version: envelope.source.version, stats: FastTransportStats { - transport: "png-chunked", - raw_bytes: envelope_bytes.len(), - wire_bytes, - chunk_count: descriptor.chunk_count, + transport: "text", + raw_bytes: utf8_bytes, + wire_bytes: utf8_bytes, + chunk_count: 0, }, }) } +/// Whether an envelope's node list is a partial page of a larger, paginated +/// fetch, derived from the `__DEVUP_SNAPSHOT_CURSOR__` marker node every fast +/// snapshot script appends (`offset`, `nextOffset`, `complete`, `totalNodes`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PageCursor { + is_first_page: bool, + is_final_page: bool, +} + +fn peek_page_cursor(nodes: &[RawNode]) -> Result { + let marker = nodes + .iter() + .filter(|node| node.id == SNAPSHOT_CURSOR_ID) + .collect::>(); + match marker.as_slice() { + // No cursor marker at all: treat as a single, complete, self-contained + // envelope (the shape every fast snapshot had before pagination). + // Real script output always includes the marker; this only matters + // for hand-built payloads (tests, older fixtures). + [] => Ok(PageCursor { + is_first_page: true, + is_final_page: true, + }), + [marker] => { + if marker.node_type != "DEVUP_INTERNAL" { + return Err(invalid("cursorShape")); + } + let view = marker.typed_view(); + let offset = view + .number("offset") + .ok_or_else(|| invalid("cursorShape"))?; + let complete = view + .bool("complete") + .ok_or_else(|| invalid("cursorShape"))?; + Ok(PageCursor { + is_first_page: offset == 0.0, + is_final_page: complete, + }) + } + _ => Err(invalid("cursorMultiplicity")), + } +} + fn normalize_upstream_result(value: &Value) -> Result, DevupError> { match value { Value::String(text) => { @@ -397,231 +276,14 @@ fn find_tagged_text( } } -fn find_images(value: &Value) -> Result, DevupError> { - fn collect<'a>(value: &'a Value, found: &mut Vec<(&'a str, &'a str)>) { - match value { - Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("image") - && let Some(data) = object.get("data").and_then(Value::as_str) - && let Some(mime) = object - .get("mimeType") - .or_else(|| object.get("mime_type")) - .and_then(Value::as_str) - { - found.push((data, mime)); - } - for child in object.values() { - collect(child, found); - } - } - Value::Array(values) => { - for child in values { - collect(child, found); - } - } - _ => {} - } - } - - let mut images = Vec::new(); - collect(value, &mut images); - if images.is_empty() { - return Err(invalid("imageMissing")); - } - if images.len() > MAX_ENVELOPE_CHUNKS { - return Err(too_large("imageCount")); - } - Ok(images) -} - -fn find_descriptor(value: &Value) -> Result { - fn collect(value: &Value, found: &mut Vec) { - match value { - Value::Object(object) => { - if let Some(Value::String(text)) = object.get("text") - && let Ok(descriptor) = serde_json::from_str::(text) - && descriptor.kind == "devupFastSnapshotDescriptor" - { - found.push(descriptor); - } - for child in object.values() { - collect(child, found); - } - } - Value::Array(values) => { - for child in values { - collect(child, found); - } - } - _ => {} - } - } - - let mut descriptors = Vec::new(); - collect(value, &mut descriptors); - match descriptors.len() { - 1 => Ok(descriptors.remove(0)), - 0 => Err(invalid("descriptorMissing")), - _ => Err(invalid("descriptorMultiplicity")), - } -} - -fn find_theme_descriptor(value: &Value) -> Result { - fn collect(value: &Value, found: &mut Vec) { - match value { - Value::Object(object) => { - if let Some(Value::String(text)) = object.get("text") - && let Ok(descriptor) = serde_json::from_str::(text) - && descriptor.kind == "devupFastThemeDescriptor" - { - found.push(descriptor); - } - for child in object.values() { - collect(child, found); - } - } - Value::Array(values) => { - for child in values { - collect(child, found); - } - } - _ => {} - } - } - - let mut descriptors = Vec::new(); - collect(value, &mut descriptors); - match descriptors.len() { - 1 => Ok(descriptors.remove(0)), - 0 => Err(invalid("descriptorMissing")), - _ => Err(invalid("descriptorMultiplicity")), - } -} - -fn decode_png_envelope(png: &[u8]) -> Result>, DevupError> { - if !png.starts_with(PNG_SIGNATURE) { - return Err(invalid("pngSignature")); - } - - let mut offset = PNG_SIGNATURE.len(); - let mut first = true; - let mut saw_idat = false; - let mut saw_iend = false; - let mut envelope_chunks = Vec::new(); - while offset < png.len() { - let header_end = offset.checked_add(8).ok_or_else(|| invalid("pngLength"))?; - if header_end > png.len() { - return Err(invalid("pngLength")); - } - let length = u32::from_be_bytes( - png[offset..offset + 4] - .try_into() - .map_err(|_| invalid("pngLength"))?, - ) as usize; - let chunk_type: &[u8; 4] = png[offset + 4..header_end] - .try_into() - .map_err(|_| invalid("pngChunkType"))?; - let data_start = header_end; - let data_end = data_start - .checked_add(length) - .ok_or_else(|| invalid("pngLength"))?; - let crc_end = data_end - .checked_add(4) - .ok_or_else(|| invalid("pngLength"))?; - if crc_end > png.len() { - return Err(invalid("pngLength")); - } - - if first { - if chunk_type != b"IHDR" || &png[data_start..data_end] != EXPECTED_IHDR { - return Err(invalid("pngIhdr")); - } - } else if chunk_type == b"IHDR" { - return Err(invalid("pngIhdr")); - } - first = false; - let expected_crc = u32::from_be_bytes( - png[data_end..crc_end] - .try_into() - .map_err(|_| invalid("pngCrc"))?, - ); - if crc32(&png[offset + 4..data_end]) != expected_crc { - return Err(invalid("pngCrc")); - } - if chunk_type == ENVELOPE_CHUNK_TYPE { - if length < 8 { - return Err(invalid("envelopeChunkHeader")); - } - let sequence = u32::from_be_bytes( - png[data_start..data_start + 4] - .try_into() - .map_err(|_| invalid("envelopeChunkHeader"))?, - ); - let total = u32::from_be_bytes( - png[data_start + 4..data_start + 8] - .try_into() - .map_err(|_| invalid("envelopeChunkHeader"))?, - ); - envelope_chunks.push((sequence, total, &png[data_start + 8..data_end])); - } - if chunk_type == b"IDAT" { - saw_idat = true; - } - if chunk_type == b"IEND" { - if length != 0 || crc_end != png.len() { - return Err(invalid("pngIend")); - } - saw_iend = true; - break; - } - offset = crc_end; - } - - if !saw_iend { - return Err(invalid("pngIend")); - } - if !saw_idat { - return Err(invalid("pngIdat")); - } - if envelope_chunks.is_empty() { - return Err(invalid("envelopeChunkMissing")); - } - Ok(envelope_chunks) -} - -fn join_envelope_chunks( - chunks: Vec>, - maximum_bytes: usize, -) -> Result, DevupError> { - let total = u32::try_from(chunks.len()).map_err(|_| too_large("chunkCount"))?; - let mut byte_count = 0_usize; - for (expected_sequence, (sequence, declared_total, bytes)) in chunks.iter().enumerate() { - if declared_total != &total || sequence != &(expected_sequence as u32) { - return Err(invalid("envelopeChunkSequence")); - } - byte_count = byte_count - .checked_add(bytes.len()) - .ok_or_else(|| too_large("envelope"))?; - if byte_count > maximum_bytes { - return Err(too_large("envelope")); - } - } - let mut output = Vec::with_capacity(byte_count); - for (_, _, bytes) in chunks { - output.extend_from_slice(bytes); - } - Ok(output) -} - fn validate_envelope( envelope: &Envelope, - descriptor: Option<&EnvelopeDescriptor>, target: &FigmaTarget, expected_root_ids: &[String], utf8_bytes: usize, + page: PageCursor, ) -> Result<(), DevupError> { if envelope.schema_version != 1 - || descriptor.is_some_and(|descriptor| descriptor.schema_version != 1) || envelope .kind .as_deref() @@ -636,14 +298,11 @@ fn validate_envelope( if envelope.source.file_key != target.file_key || envelope.snapshot.file_key != target.file_key || envelope.source.root_id != target_root - || descriptor.is_some_and(|descriptor| descriptor.root_id != target_root) || envelope.snapshot.root_ids != expected_root_ids { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes - || descriptor.is_some_and(|descriptor| descriptor.utf8_bytes != utf8_bytes) - { + if envelope.integrity.utf8_bytes != utf8_bytes { return Err(invalid("utf8Bytes")); } @@ -653,28 +312,34 @@ fn validate_envelope( return Err(invalid("duplicateNode")); } } + // The root is only guaranteed present on the first page of a paginated + // fetch (BFS traversal always visits it at index 0); later pages cover + // only a later slice of the same subtree. if envelope.integrity.node_count != node_ids.len() - || descriptor.is_some_and(|descriptor| descriptor.node_count != node_ids.len()) - || !expected_root_ids - .iter() - .all(|root_id| node_ids.contains(root_id.as_str())) + || (page.is_first_page + && !expected_root_ids + .iter() + .all(|root_id| node_ids.contains(root_id.as_str()))) { return Err(invalid("nodeCount")); } - for node in &envelope.snapshot.nodes { - for child_id in node.typed_view().child_ids() { - if !node_ids.contains(child_id) { - return Err(invalid("danglingChild")); + // A child referenced by a node in this page may legitimately live in a + // later page while pagination is still in progress. Once the fetch is + // complete (this is the final page), every remaining node has already + // been sent, so full containment is enforced again. + if page.is_final_page { + for node in &envelope.snapshot.nodes { + for child_id in node.typed_view().child_ids() { + if !node_ids.contains(child_id) { + return Err(invalid("danglingChild")); + } } } } let refs = collect_used_resource_refs(std::slice::from_ref(&envelope.snapshot)); if envelope.integrity.variable_ref_count != refs.variable_ids.len() - || descriptor - .is_some_and(|descriptor| descriptor.variable_ref_count != refs.variable_ids.len()) || envelope.integrity.style_ref_count != refs.styles.len() - || descriptor.is_some_and(|descriptor| descriptor.style_ref_count != refs.styles.len()) { return Err(invalid("resourceRefCount")); } @@ -684,12 +349,10 @@ fn validate_envelope( fn validate_theme_envelope( envelope: &ThemeEnvelope, - descriptor: Option<&ThemeEnvelopeDescriptor>, expected_file_key: &str, utf8_bytes: usize, ) -> Result<(), DevupError> { if envelope.schema_version != 1 - || descriptor.is_some_and(|descriptor| descriptor.schema_version != 1) || envelope .kind .as_deref() @@ -700,9 +363,7 @@ fn validate_theme_envelope( if envelope.source.file_key != expected_file_key { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes - || descriptor.is_some_and(|descriptor| descriptor.utf8_bytes != utf8_bytes) - { + if envelope.integrity.utf8_bytes != utf8_bytes { return Err(invalid("utf8Bytes")); } let resources = envelope @@ -718,31 +379,17 @@ fn validate_theme_envelope( .ok_or_else(|| invalid("unresolvedShape"))?; validate_theme_count( envelope.integrity.collection_count, - descriptor.map_or(envelope.integrity.collection_count, |value| { - value.collection_count - }), collections.len(), "collectionCount", )?; validate_theme_count( envelope.integrity.variable_count, - descriptor.map_or(envelope.integrity.variable_count, |value| { - value.variable_count - }), variables.len(), "variableCount", )?; - validate_theme_count( - envelope.integrity.style_count, - descriptor.map_or(envelope.integrity.style_count, |value| value.style_count), - styles.len(), - "styleCount", - )?; + validate_theme_count(envelope.integrity.style_count, styles.len(), "styleCount")?; validate_theme_count( envelope.integrity.unresolved_count, - descriptor.map_or(envelope.integrity.unresolved_count, |value| { - value.unresolved_count - }), unresolved.len(), "unresolvedCount", )?; @@ -767,11 +414,10 @@ fn validate_theme_envelope( fn validate_theme_count( envelope_count: usize, - descriptor_count: usize, observed_count: usize, category: &'static str, ) -> Result<(), DevupError> { - if envelope_count != observed_count || descriptor_count != observed_count { + if envelope_count != observed_count { Err(invalid(category)) } else { Ok(()) @@ -844,17 +490,6 @@ fn resource_ids<'a>( .collect() } -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} - fn invalid(category: &'static str) -> DevupError { DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, diff --git a/crates/devup-mcp-figma/src/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index 0bbbd62..2b4dc1f 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -1,25 +1,18 @@ [ - "absoluteBoundingBox", "absoluteRenderBounds", "annotations", "arcData", "attachedConnectors", - "authorVisible", "backgrounds", "backgroundStyleId", "blendMode", "bottomLeftRadius", - "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentDescription", - "componentProperties", "componentPropertyDefinitions", "componentPropertyReferences", "componentSetId", "componentSetProperties", - "constraints", "cornerRadius", "cornerSmoothing", "counterAxisAlignContent", "counterAxisAlignItems", - "counterAxisSizingMode", "dashPattern", "description", "detachedInfo", "devStatus", "documentationLinks", "effects", - "effectStyleId", "expanded", "explicitVariableModes", "exportSettings", "exposedInstances", "fills", "fillStyleId", - "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridColumnSpan", - "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridRowSpan", "gridStyleId", "guides", "height", - "hyperlink", "inferredAutoLayout", "isAsset", "isExposedInstance", "isMask", "isMaskOutline", "itemReverseZIndex", - "itemSpacing", "layoutAlign", "layoutGrids", "layoutGrow", "layoutMode", "layoutPositioning", - "layoutSizingHorizontal", "layoutSizingVertical", "layoutWrap", "letterSpacing", "lineHeight", - "locked", "mainAxisAlignItems", "mainAxisSizingMode", "maskType", "maxHeight", "maxWidth", "measurements", - "minHeight", "minWidth", "name", "numberOfFixedChildren", "opacity", "overlayBackground", - "overlayBackgroundInteraction", "overlayPositionType", "overflowDirection", "paddingBottom", "paddingLeft", "paddingRight", - "paddingTop", "paragraphIndent", "paragraphSpacing", "paragraphSpacingMode", "pluginData", "primaryAxisAlignItems", "reactions", - "relativeTransform", "remote", "removed", "resizeHandlePlacement", "resolvedVariableModes", "rotation", - "scrollBehavior", "sharedPluginData", "strokes", "strokeAlign", "strokeBottomWeight", "strokeCap", - "strokeJoin", "strokeLeftWeight", "strokeMiterLimit", "strokeRightWeight", "strokeStyleId", - "strokeTopWeight", "strokeWeight", "stuckNodes", "targetAspectRatio", "textAlignHorizontal", - "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", "topLeftRadius", - "topRightRadius", "triggeredInteractions", "truncation", "variantProperties", "vectorNetwork", "visible", - "width", "x", "y" + "absoluteBoundingBox", "annotations", "arcData", "backgroundStyleId", "blendMode", + "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", + "componentProperties", "componentPropertyDefinitions", "componentPropertyReferences", "constraints", "cornerRadius", + "counterAxisAlignItems", "dashPattern", "effects", "effectStyleId", "fills", + "fillStyleId", "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", + "gridColumnGap", "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridStyleId", + "height", "inferredAutoLayout", "isAsset", "isMask", "itemSpacing", + "layoutGrow", "layoutMode", "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", + "letterSpacing", "lineHeight", "maxHeight", "maxWidth", "minHeight", + "minWidth", "name", "opacity", "paddingBottom", "paddingLeft", + "paddingRight", "paddingTop", "primaryAxisAlignItems", "reactions", "rotation", + "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokes", + "strokeStyleId", "strokeTopWeight", "strokeWeight", "targetAspectRatio", "textAlignHorizontal", + "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", + "topLeftRadius", "topRightRadius", "variantProperties", "visible", "width", + "x", "y" ] diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 35b1af3..9ca35f2 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -10,26 +10,57 @@ if (roots.length === 1 && roots[0].type === "SECTION") { const envelopeRootId = "__DEVUP_NODE_ID__"; const manifest = "__DEVUP_PLUGIN_API_MANIFEST__"; -const manifestSet = new Set(manifest); const textSegmentManifest = "__DEVUP_TEXT_SEGMENT_MANIFEST__"; -const skipped = new Set(["id", "type", "parent", "children"]); -const MAX_ENVELOPE_BYTES = 1024 * 1024; -const MAX_ENVELOPE_CHUNK_BYTES = 512 * 1024; +const pageOptions = "__DEVUP_SNAPSHOT__"; +const offset = Math.max(0, Math.floor(Number(pageOptions.offset) || 0)); +// Upper bound for one round's serialized payload. Kept well under the ~20,500 +// character Figma MCP text-response limit so a page always survives as text +// (no PNG fallback exists any more). +const maxPayloadBytes = Math.min( + 18000, + Math.max(4096, Math.floor(Number(pageOptions.maxPayloadBytes) || 12000)), +); const MAX_TEXT_ENVELOPE_BYTES = 15 * 1024; +const MAX_ENVELOPE_BYTES = 1024 * 1024; + +// Values that carry no information beyond "this field is at its default" are +// dropped from the envelope. Consumers must treat an absent key exactly like +// its default (this already holds for every accessor in the Rust codegen, +// which reads through Option-returning TypedNode helpers). +// +// `""` is only dropped for *StyleId fields: Figma reports an unbound style +// as `""`, and both consumers of these fields already treat `""` and +// "field absent" identically — +// - `resources.rs::is_resource_id` rejects empty IDs before treating a +// *StyleId field as a real style reference (used by both the fast-path +// JS resource scanner above and the legacy Rust scanner), and +// - `codegen/text.rs` looks `textStyleId` up in a token map, where an +// empty-string key can never match (same `None` result as a missing key). +const STYLE_ID_FIELDS = new Set([ + "backgroundStyleId", + "effectStyleId", + "fillStyleId", + "gridStyleId", + "strokeStyleId", + "textStyleId", +]); +function isOmittableDefault(value, name) { + if (value === null) return true; + if (Array.isArray(value) && value.length === 0) return true; + if (value === "" && STYLE_ID_FIELDS.has(name)) return true; + return false; +} function propertyNames(value) { - const names = new Set(); - let current = value; - while (current && current !== Object.prototype) { - for (const name of Object.getOwnPropertyNames(current)) names.add(name); - current = Object.getPrototypeOf(current); - } + // Only ever look at the checked-in manifest. No prototype-chain walk, no + // "extra" bucket: an unlisted Figma Plugin API property is never collected. + const names = []; for (const name of manifest) { try { - if (name in value) names.add(name); + if (name in value) names.push(name); } catch (_) {} } - return [...names].sort(); + return names; } function serialize(value, seen = new WeakSet(), depth = 0) { @@ -68,30 +99,29 @@ function serialize(value, seen = new WeakSet(), depth = 0) { function snapshotNode(node) { const fields = {}; - const extra = {}; const fieldErrors = {}; fields.parentId = node.parent ? node.parent.id : null; fields.childrenIds = "children" in node ? node.children.map((child) => child.id) : []; for (const name of propertyNames(node)) { - if (skipped.has(name) || name.startsWith("_")) continue; try { const value = node[name]; if (typeof value === "function") continue; const serialized = serialize(value); - (manifestSet.has(name) ? fields : extra)[name] = serialized; + if (!isOmittableDefault(serialized, name)) fields[name] = serialized; } catch (error) { fieldErrors[name] = String(error && error.message ? error.message : error); } } if (node.type === "TEXT" && typeof node.getStyledTextSegments === "function") { try { - fields.styledTextSegments = serialize(node.getStyledTextSegments(textSegmentManifest)); + const segments = serialize(node.getStyledTextSegments(textSegmentManifest)); + if (!isOmittableDefault(segments)) fields.styledTextSegments = segments; } catch (error) { fieldErrors.styledTextSegments = String(error && error.message ? error.message : error); } } - return { id: node.id, type: node.type, fields, extra, fieldErrors }; + return { id: node.id, type: node.type, fields, extra: {}, fieldErrors }; } const allNodes = []; @@ -104,7 +134,51 @@ for (let index = 0; index < queue.length; index += 1) { allNodes.push(node); if ("children" in node) queue.push(...node.children); } -const nodes = allNodes.map(snapshotNode); +if (offset >= allNodes.length && allNodes.length > 0) { + throw new Error("DEVUP_SNAPSHOT_RANGE_INVALID"); +} + +function utf8ByteLength(value) { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + bytes += 4; + index += 1; + } else bytes += 3; + } + return bytes; +} + +function jsonByteLength(value) { + return utf8ByteLength(JSON.stringify(value)); +} + +// Pack as many nodes as fit under maxPayloadBytes starting at offset. This is +// the same dynamic, byte-budget-driven pagination the legacy cursor snapshot +// already uses, applied to the fast (single-call, resource-inclusive) path. +const pageNodeBudget = maxPayloadBytes - 1024; +const pageNodes = []; +let pagePayloadBytes = 2; +for (let index = offset; index < allNodes.length; index += 1) { + const snapshotted = snapshotNode(allNodes[index]); + const nodeBytes = jsonByteLength(snapshotted) + (pageNodes.length ? 1 : 0); + if (pageNodes.length && pagePayloadBytes + nodeBytes > pageNodeBudget) break; + pageNodes.push(snapshotted); + pagePayloadBytes += nodeBytes; +} +const nextOffset = Math.min(allNodes.length, offset + pageNodes.length); +const complete = nextOffset >= allNodes.length; +const nodes = pageNodes; +nodes.push({ + id: "__DEVUP_SNAPSHOT_CURSOR__", + type: "DEVUP_INTERNAL", + fields: { nextOffset, complete, totalNodes: allNodes.length }, + extra: {}, + fieldErrors: {}, +}); function styleTypeForField(field) { if (field === "textStyleId") return "TEXT"; @@ -145,6 +219,9 @@ function scanResources(value, fieldName = "") { scanResources(child, field || fieldName); } } +// Only the nodes actually shipped in THIS page are scanned, so the resources +// this page returns stay self-consistent with this page's own integrity +// counters. The host (devup-mcp) merges resources across pages. scanResources(nodes); function resourcePropertyNames(value) { @@ -321,6 +398,7 @@ const envelope = { usedRemoteComplete: unresolved.length === 0, unresolved, }, + pagination: { offset, nextOffset, complete, totalNodes: allNodes.length }, integrity: { nodeCount: nodes.length, variableRefCount: sortedVariableIds.length, @@ -342,75 +420,11 @@ if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { if (envelopeBytes.length > MAX_ENVELOPE_BYTES) { throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); } -if (envelopeBytes.length <= MAX_TEXT_ENVELOPE_BYTES) return envelope; - -function crc32(bytes) { - let crc = 0xffffffff; - for (const byte of bytes) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - return (crc ^ 0xffffffff) >>> 0; -} - -function u32(value) { - return new Uint8Array([ - (value >>> 24) & 0xff, - (value >>> 16) & 0xff, - (value >>> 8) & 0xff, - value & 0xff, - ]); -} - -function ascii(value) { - return new Uint8Array([...value].map((character) => character.charCodeAt(0))); -} - -function concat(parts) { - const length = parts.reduce((sum, part) => sum + part.length, 0); - const output = new Uint8Array(length); - let offset = 0; - for (const part of parts) { - output.set(part, offset); - offset += part.length; - } - return output; -} - -function pngChunk(type, data) { - const typeBytes = ascii(type); - return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]); -} - -const chunkCount = Math.ceil(envelopeBytes.length / MAX_ENVELOPE_CHUNK_BYTES); -for (let sequence = 0; sequence < chunkCount; sequence += 1) { - const start = sequence * MAX_ENVELOPE_CHUNK_BYTES; - const end = Math.min(envelopeBytes.length, start + MAX_ENVELOPE_CHUNK_BYTES); - const envelopeChunk = pngChunk( - "duVp", - concat([u32(sequence), u32(chunkCount), envelopeBytes.slice(start, end)]), - ); - const png = concat([ - new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), - pngChunk("IHDR", new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0])), - envelopeChunk, - pngChunk( - "IDAT", - new Uint8Array([120, 1, 1, 5, 0, 250, 255, 0, 0, 0, 0, 0, 5, 0, 1]), - ), - pngChunk("IEND", new Uint8Array()), - ]); - figma.io.write(`devup-fast-snapshot-${sequence + 1}-of-${chunkCount}.png`, png); +if (envelopeBytes.length > MAX_TEXT_ENVELOPE_BYTES) { + // The byte budget above is sized to stay under this safety margin; a + // single misbehaving node (huge boundVariables/componentProperties tree) + // is the only way to reach here. Surface it as a hard error instead of + // silently falling back to an unsupported binary transport. + throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); } -return { - kind: "devupFastSnapshotDescriptor", - schemaVersion: 1, - rootId: envelopeRootId, - nodeCount: nodes.length, - variableRefCount: sortedVariableIds.length, - styleRefCount: sortedStyles.length, - utf8Bytes: envelopeBytes.length, - chunkCount, -}; +return envelope; diff --git a/crates/devup-mcp-figma/src/scripts/fast_theme.js b/crates/devup-mcp-figma/src/scripts/fast_theme.js index 102167c..5199631 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_theme.js +++ b/crates/devup-mcp-figma/src/scripts/fast_theme.js @@ -1,5 +1,4 @@ const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024; -const MAX_ENVELOPE_CHUNK_BYTES = 512 * 1024; const MAX_TEXT_ENVELOPE_BYTES = 15 * 1024; function propertyNames(value) { @@ -271,56 +270,11 @@ if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); } if (envelopeBytes.length > MAX_ENVELOPE_BYTES) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); -if (envelopeBytes.length <= MAX_TEXT_ENVELOPE_BYTES) return envelope; - -function crc32(bytes) { - let crc = 0xffffffff; - for (const byte of bytes) { - crc ^= byte; - for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - return (crc ^ 0xffffffff) >>> 0; -} -function u32(value) { - return new Uint8Array([(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]); -} -function ascii(value) { - return new Uint8Array([...value].map((character) => character.charCodeAt(0))); -} -function concat(parts) { - const output = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); - let offset = 0; - for (const part of parts) { - output.set(part, offset); - offset += part.length; - } - return output; -} -function pngChunk(type, data) { - const typeBytes = ascii(type); - return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]); -} - -const chunkCount = Math.ceil(envelopeBytes.length / MAX_ENVELOPE_CHUNK_BYTES); -for (let sequence = 0; sequence < chunkCount; sequence += 1) { - const start = sequence * MAX_ENVELOPE_CHUNK_BYTES; - const end = Math.min(envelopeBytes.length, start + MAX_ENVELOPE_CHUNK_BYTES); - const png = concat([ - new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), - pngChunk("IHDR", new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0])), - pngChunk("duVp", concat([u32(sequence), u32(chunkCount), envelopeBytes.slice(start, end)])), - pngChunk("IDAT", new Uint8Array([120, 1, 1, 5, 0, 250, 255, 0, 0, 0, 0, 0, 5, 0, 1])), - pngChunk("IEND", new Uint8Array()), - ]); - figma.io.write(`devup-fast-theme-${sequence + 1}-of-${chunkCount}.png`, png); +if (envelopeBytes.length > MAX_TEXT_ENVELOPE_BYTES) { + // No binary transport exists any more (real-world hosts silently + // discarded the old PNG-chunked image attachments). A file-wide theme + // that doesn't fit as text falls back to the legacy per-resource + // collection path, which already handles arbitrarily large theme scopes. + throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); } -return { - kind: "devupFastThemeDescriptor", - schemaVersion: 1, - collectionCount: collections.length, - variableCount: variables.length, - styleCount: styles.length, - unresolvedCount: unresolved.length, - utf8Bytes: envelopeBytes.length, - chunkCount, -}; +return envelope; diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index bd2463e..f977490 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -440,12 +440,24 @@ impl ReadToolCall { } pub fn fast_snapshot(file_key: impl Into, node_id: impl Into) -> Self { + Self::fast_snapshot_page(file_key, node_id, SnapshotReadOptions::default()) + } + + /// A single round of the fast (text-paginated) node snapshot. `options.offset` + /// selects the starting node index; the script dynamically packs as many + /// nodes as fit under `options.max_payload_bytes` and reports a cursor for + /// the next round via the standard `__DEVUP_SNAPSHOT_CURSOR__` marker node. + pub fn fast_snapshot_page( + file_key: impl Into, + node_id: impl Into, + options: SnapshotReadOptions, + ) -> Self { Self::Snapshot { file_key: file_key.into(), node_id: node_id.into(), script: BuiltinScript::FastSnapshotEnvelope, resources: None, - snapshot: None, + snapshot: Some(options), root_ids: None, } } @@ -589,6 +601,13 @@ impl ReadToolCall { | Self::Screenshot { file_key, node_id } => { json!({ "fileKey": file_key, "nodeId": node_id }) } + // These variants all route to the official `use_figma` tool, whose + // schema is `{ fileKey, code, description, skillNames? }` with + // `additionalProperties: false`. `nodeId` is NOT part of that + // schema and must never appear here (real Figma MCP hosts reject + // unknown properties); the node this call targets is tracked + // separately in `PlannedCall::expected_node_id` and surfaced to + // handoff consumers outside `arguments`, not inside it. Self::Snapshot { file_key, node_id, @@ -598,7 +617,7 @@ impl ReadToolCall { root_ids, } => json!({ "fileKey": file_key, - "nodeId": node_id, + "description": self.description(), "code": script.source(node_id, ScriptInputs { resources: resources.as_ref(), snapshot: snapshot.as_ref(), @@ -612,7 +631,7 @@ impl ReadToolCall { options, } => json!({ "fileKey": file_key, - "nodeId": node_id, + "description": self.description(), "code": BuiltinScript::SearchSnapshot.source(node_id, ScriptInputs { search: Some(options), ..ScriptInputs::default() @@ -620,6 +639,7 @@ impl ReadToolCall { }), Self::PageCatalog { file_key } => json!({ "fileKey": file_key, + "description": self.description(), "code": BuiltinScript::PageCatalog.source("", ScriptInputs::default()) }), Self::ExploreSnapshot { @@ -628,7 +648,7 @@ impl ReadToolCall { options, } => json!({ "fileKey": file_key, - "nodeId": node_id, + "description": self.description(), "code": BuiltinScript::ExploreSnapshot.source(node_id, ScriptInputs { explore: Some(options), ..ScriptInputs::default() @@ -636,11 +656,12 @@ impl ReadToolCall { }), Self::FastTheme { file_key } => json!({ "fileKey": file_key, + "description": self.description(), "code": BuiltinScript::FastThemeEnvelope.source("", ScriptInputs::default()) }), Self::LargeValue { file_key, options } => json!({ "fileKey": file_key, - "nodeId": options.node_id, + "description": self.description(), "code": BuiltinScript::LargeValue.source(&options.node_id, ScriptInputs { large_value: Some(options), ..ScriptInputs::default() @@ -652,7 +673,7 @@ impl ReadToolCall { request, } => json!({ "fileKey": file_key, - "nodeId": request.node_id, + "description": self.description(), "code": BuiltinScript::AssetExport.source(&request.node_id, ScriptInputs { asset: Some((request, version.as_deref())), ..ScriptInputs::default() @@ -661,6 +682,63 @@ impl ReadToolCall { }; value.as_object().cloned().unwrap_or_default() } + + /// Human-readable `description` required by the official `use_figma` + /// schema. Only meaningful for the `use_figma`-routed variants; other + /// variants never reach this (their `arguments()` don't call it). + fn description(&self) -> String { + let node_id = self.node_id_for_description(); + match self { + Self::Snapshot { script, .. } => match script { + BuiltinScript::FastSnapshotEnvelope | BuiltinScript::MultiRootSnapshotEnvelope => { + format!("devup-mcp fast node snapshot for node {node_id} (read-only)") + } + BuiltinScript::NodeSnapshot => { + format!("devup-mcp paginated node snapshot for node {node_id} (read-only)") + } + BuiltinScript::SectionIndex => { + format!("devup-mcp Section screen index for node {node_id} (read-only)") + } + BuiltinScript::VariableCatalog => { + format!("devup-mcp local variable/style catalog for node {node_id} (read-only)") + } + BuiltinScript::LocalVariables | BuiltinScript::UsedResources => { + format!( + "devup-mcp variable/style resource batch for node {node_id} (read-only)" + ) + } + _ => format!("devup-mcp Figma read for node {node_id} (read-only)"), + }, + Self::SearchSnapshot { .. } => { + format!("devup-mcp page-scoped name search for node {node_id} (read-only)") + } + Self::PageCatalog { .. } => "devup-mcp file page catalog (read-only)".to_owned(), + Self::ExploreSnapshot { .. } => { + format!("devup-mcp screen candidate exploration near node {node_id} (read-only)") + } + Self::FastTheme { .. } => { + "devup-mcp fast file-wide theme snapshot (read-only)".to_owned() + } + Self::LargeValue { .. } => { + format!("devup-mcp large field value fragment for node {node_id} (read-only)") + } + Self::AssetExport { .. } => { + format!("devup-mcp asset export for node {node_id} (read-only)") + } + _ => "devup-mcp Figma read (read-only)".to_owned(), + } + } + + fn node_id_for_description(&self) -> &str { + match self { + Self::Snapshot { node_id, .. } + | Self::SearchSnapshot { node_id, .. } + | Self::ExploreSnapshot { node_id, .. } => node_id, + Self::LargeValue { options, .. } => &options.node_id, + Self::AssetExport { request, .. } => &request.node_id, + _ => "", + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 1958ebc..78b082e 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -18,11 +18,19 @@ fn exact_node_fast_path_completes_in_one_call() { panic!("fast snapshot call expected") }; assert_eq!(fast_call.call.tool_name(), "use_figma"); + let arguments = fast_call.call.arguments(); + assert!(!arguments.contains_key("nodeId")); assert!( - fast_call.call.arguments()["code"] + arguments["code"] .as_str() .unwrap() - .contains("devupFastSnapshotDescriptor") + .contains("devupFastSnapshotEnvelope") + ); + assert!( + !arguments["code"] + .as_str() + .unwrap() + .contains("figma.io.write") ); collector @@ -35,7 +43,7 @@ fn exact_node_fast_path_completes_in_one_call() { assert_eq!(parts.snapshot_chunks.len(), 1); assert_eq!(parts.snapshot_chunks[0].nodes.len(), 1); assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-chunked"); + assert_eq!(parts.stats.transport, "text"); assert!(!parts.stats.fallback_used); assert_eq!(parts.stats.node_count, 1); assert_eq!(parts.stats.variable_count, 0); @@ -68,7 +76,7 @@ fn exact_node_fast_path_accepts_the_stringified_handoff_contract() { panic!("stringified fast snapshot should complete without fallback") }; assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-chunked"); + assert_eq!(parts.stats.transport, "text"); assert!(!parts.stats.fallback_used); } @@ -302,7 +310,7 @@ fn malformed_fast_result_restarts_legacy_from_metadata() { assert!(parts.stats.fallback_used); assert_eq!( parts.stats.fallback_reason.as_deref(), - Some("descriptorMissing") + Some("textEnvelopeMissing") ); assert_eq!(parts.stats.node_count, 1); } @@ -422,6 +430,7 @@ fn valid_reference_png_base64() -> &'static str { fn fast_envelope_result() -> UpstreamResult { let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "1:2"}, "snapshot": { @@ -460,42 +469,20 @@ fn fast_envelope_result() -> UpstreamResult { } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:2", - "nodeCount": 1, - "variableRefCount": 0, - "styleRefCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + let _ = envelope_bytes; + // No binary transport exists any more: fast snapshots are always plain + // text. Omitting the `__DEVUP_SNAPSHOT_CURSOR__` marker node is treated + // by the decoder as a single, already-complete page. UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } fn fast_theme_envelope_result() -> UpstreamResult { let mut envelope = json!({ + "kind": "devupFastThemeEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "version": "v2"}, "resources": { @@ -524,60 +511,14 @@ fn fast_theme_envelope_result() -> UpstreamResult { } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastThemeDescriptor", - "schemaVersion": 1, - "collectionCount": 1, - "variableCount": 1, - "styleCount": 1, - "unresolvedCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + let _ = envelope_bytes; UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } -fn push_png_chunk(output: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(chunk_type); - output.extend_from_slice(data); - let mut crc_input = Vec::with_capacity(4 + data.len()); - crc_input.extend_from_slice(chunk_type); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} - fn file_target() -> FigmaTarget { FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture").unwrap() } @@ -767,11 +708,13 @@ fn variables_only_file_collection_skips_page_and_node_snapshots() { panic!("fast theme call expected") }; assert_eq!(fast_theme.call.tool_name(), "use_figma"); + let arguments = fast_theme.call.arguments(); + assert!(!arguments.contains_key("nodeId")); assert!( - fast_theme.call.arguments()["code"] + arguments["code"] .as_str() .unwrap() - .contains("devupFastThemeDescriptor") + .contains("devupFastThemeEnvelope") ); collector .accept(&fast_theme.id, fast_theme_envelope_result()) @@ -781,7 +724,7 @@ fn variables_only_file_collection_skips_page_and_node_snapshots() { panic!("valid fast theme should complete in one call") }; assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "png-chunked"); + assert_eq!(parts.stats.transport, "text"); assert!(!parts.stats.fallback_used); assert_eq!(parts.stats.variable_count, 1); assert_eq!(parts.stats.style_count, 1); @@ -1916,6 +1859,7 @@ fn fast_multi_envelope_result(root_ids: &[&str], variable_ids: &[&str]) -> Upstr .map(|id| json!({"id": id, "name": id})) .collect::>(); let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "10:1"}, "snapshot": { @@ -1950,35 +1894,10 @@ fn fast_multi_envelope_result(root_ids: &[&str], variable_ids: &[&str]) -> Upstr } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "10:1", - "nodeCount": root_ids.len(), - "variableRefCount": variable_ids.len(), - "styleRefCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + let _ = envelope_bytes; UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } diff --git a/crates/devup-mcp-figma/tests/envelope.rs b/crates/devup-mcp-figma/tests/envelope.rs index 001350f..4ba3e7c 100644 --- a/crates/devup-mcp-figma/tests/envelope.rs +++ b/crates/devup-mcp-figma/tests/envelope.rs @@ -1,19 +1,21 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp_figma::{ FigmaTarget, UpstreamResult, decode_fast_multi_snapshot, decode_fast_snapshot, decode_fast_theme, }; use serde_json::{Value, json}; -const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; +// No binary (PNG-chunked) transport exists any more — real-world hosts +// silently discarded the old image attachments, so it never actually worked +// end to end. Fast snapshots and fast themes are delivered as plain text +// only now; a node subtree that doesn't fit in one round is paginated across +// several text rounds instead (see the `paginated_*` tests below). #[test] -fn valid_multichunk_envelope_round_trips() { - let target = target(); +fn valid_snapshot_envelope_round_trips_without_an_image() { let envelope = complete_envelope(); - let result = upstream_result(envelope.clone(), 2); + let result = text_upstream_result(&envelope); - let decoded = decode_fast_snapshot(&result, &target).expect("valid envelope"); + let decoded = decode_fast_snapshot(&result, &target()).expect("valid text envelope"); assert_eq!(decoded.snapshot.file_key, "fileKey123"); assert_eq!(decoded.snapshot.root_ids, ["1:1"]); @@ -24,51 +26,23 @@ fn valid_multichunk_envelope_round_trips() { ); assert_eq!(decoded.resources.raw["styles"].as_array().unwrap().len(), 1); assert_eq!(decoded.stats.raw_bytes, envelope.len()); - assert!(decoded.stats.wire_bytes > envelope.len()); - assert_eq!(decoded.stats.chunk_count, 2); - assert_eq!(decoded.stats.transport, "png-chunked"); -} - -#[test] -fn tagged_text_snapshot_envelope_round_trips_without_an_image() { - let envelope = complete_envelope(); - let result = text_upstream_result(&envelope); - - let decoded = decode_fast_snapshot(&result, &target()).expect("valid text envelope"); - - assert_eq!(decoded.snapshot.root_ids, ["1:1"]); - assert_eq!(decoded.stats.raw_bytes, envelope.len()); assert_eq!(decoded.stats.wire_bytes, envelope.len()); assert_eq!(decoded.stats.chunk_count, 0); assert_eq!(decoded.stats.transport, "text"); } -#[test] -fn valid_multi_image_envelope_round_trips() { - let target = target(); - let envelope = complete_envelope(); - let result = upstream_result_with_split_pngs(envelope.clone(), 2); - - let decoded = decode_fast_snapshot(&result, &target).expect("valid split envelope"); - - assert_eq!(decoded.snapshot.nodes.len(), 2); - assert_eq!(decoded.stats.raw_bytes, envelope.len()); - assert_eq!(decoded.stats.chunk_count, 2); - assert!(decoded.stats.wire_bytes > envelope.len()); -} - #[test] fn json_stringified_official_mcp_result_round_trips() { let target = target(); let envelope = complete_envelope(); - let mut result = upstream_result_with_split_pngs(envelope, 2); + let mut result = text_upstream_result(&envelope); result.raw = Value::String(result.raw.to_string()); let decoded = decode_fast_snapshot(&result, &target) .expect("official handoff schema transports the MCP result as a JSON string"); assert_eq!(decoded.snapshot.root_ids, ["1:1"]); - assert_eq!(decoded.stats.chunk_count, 2); + assert_eq!(decoded.stats.transport, "text"); } #[test] @@ -86,17 +60,52 @@ fn oversized_stringified_upstream_result_is_rejected_before_json_decode() { assert_eq!(error.details["category"], "upstreamResultJson"); } +#[test] +fn a_text_envelope_over_the_15kb_safety_margin_is_rejected() { + let envelope = mutate_envelope(|value| { + value["snapshot"]["nodes"][1]["fields"]["characters"] = json!("x".repeat(20 * 1024)); + }); + let result = text_upstream_result(&envelope); + + let error = decode_fast_snapshot(&result, &target()).expect_err("oversized text envelope"); + + assert_eq!(error.details["category"], "textEnvelope"); +} + +#[test] +fn missing_fast_envelope_text_is_rejected() { + let result = UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "not an envelope"}]}), + }; + + let error = decode_fast_snapshot(&result, &target()).expect_err("no tagged envelope"); + + assert_eq!(error.details["category"], "textEnvelopeMissing"); +} + +#[test] +fn duplicate_tagged_envelopes_are_rejected() { + let envelope = complete_envelope(); + let text = std::str::from_utf8(&envelope).unwrap(); + let result = UpstreamResult { + raw: json!({"content": [ + {"type": "text", "text": text}, + {"type": "text", "text": text} + ]}), + }; + + let error = decode_fast_snapshot(&result, &target()).expect_err("duplicate envelope text"); + + assert_eq!(error.details["category"], "textEnvelopeMultiplicity"); +} + #[test] fn valid_multi_root_envelope_requires_the_exact_ordered_root_set() { let envelope = mutate_envelope(|value| { value["source"]["rootId"] = json!("9:9"); value["snapshot"]["rootIds"] = json!(["1:1", "1:2"]); }); - let mut result = upstream_result(envelope, 1); - let mut descriptor: Value = - serde_json::from_str(result.raw["content"][0]["text"].as_str().unwrap()).unwrap(); - descriptor["rootId"] = json!("9:9"); - result.raw["content"][0]["text"] = json!(descriptor.to_string()); + let result = text_upstream_result(&envelope); let section_target = FigmaTarget { node_id: Some("9:9".to_owned()), ..target() @@ -120,139 +129,20 @@ fn valid_multi_root_envelope_requires_the_exact_ordered_root_set() { } #[test] -fn out_of_order_chunks_are_rejected() { - let envelope = complete_envelope(); - let png = envelope_png_with_order(&envelope, &[1, 0]); - let result = upstream_result_with_png(png, envelope.len(), 2); - - let error = decode_fast_snapshot(&result, &target()).expect_err("out of order chunks"); - - assert_eq!(error.details["category"], "envelopeChunkSequence"); -} - -#[test] -fn noncanonical_png_header_is_rejected() { - let envelope = complete_envelope(); - let png = envelope_png_with_ihdr(&envelope, &[0, 0, 0, 2, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let result = upstream_result_with_png(png, envelope.len(), 1); - - let error = decode_fast_snapshot(&result, &target()).expect_err("noncanonical PNG"); - - assert_eq!(error.details["category"], "pngIhdr"); -} - -#[test] -fn png_without_idat_is_rejected() { - let envelope = complete_envelope(); - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut data = Vec::with_capacity(envelope.len() + 8); - data.extend_from_slice(&0_u32.to_be_bytes()); - data.extend_from_slice(&1_u32.to_be_bytes()); - data.extend_from_slice(&envelope); - push_chunk(&mut png, b"duVp", &data); - push_chunk(&mut png, b"IEND", &[]); - - assert_category( - upstream_result_with_png(png, envelope.len(), 1), - &target(), - "pngIdat", - ); -} - -#[test] -fn invalid_utf8_is_rejected_before_json_decode() { - let bytes = vec![0xff, 0xfe, 0xfd]; - let result = upstream_result_with_png(envelope_png(&bytes, 1), bytes.len(), 1); - - let error = decode_fast_snapshot(&result, &target()).expect_err("invalid UTF-8"); - - assert_eq!(error.details["category"], "envelopeUtf8"); -} - -#[test] -fn corrupt_transport_shapes_are_rejected_without_panicking() { - let envelope = complete_envelope(); - - let mut bad_signature = envelope_png(&envelope, 1); - bad_signature[0] = 0; - assert_category( - upstream_result_with_png(bad_signature, envelope.len(), 1), - &target(), - "pngSignature", - ); - - let mut bad_crc = envelope_png(&envelope, 1); - let marker = bad_crc - .windows(4) - .position(|window| window == b"duVp") - .unwrap(); - bad_crc[marker + 12] ^= 1; - assert_category( - upstream_result_with_png(bad_crc, envelope.len(), 1), - &target(), - "pngCrc", - ); - - let mut truncated = envelope_png(&envelope, 1); - truncated.pop(); - assert_category( - upstream_result_with_png(truncated, envelope.len(), 1), - &target(), - "pngLength", - ); - +fn schema_target_and_resource_integrity_are_validated() { + let unsupported = mutate_envelope(|value| value["schemaVersion"] = Value::from(2)); assert_category( - upstream_result_with_png( - envelope_png_with_order(&envelope, &[0, 0]), - envelope.len(), - 2, - ), + text_upstream_result(&unsupported), &target(), - "envelopeChunkSequence", + "schemaVersion", ); -} - -#[test] -fn image_content_contract_is_strict() { - let envelope = complete_envelope(); - - let mut missing = upstream_result(envelope.clone(), 1); - missing.raw["content"].as_array_mut().unwrap().truncate(1); - assert_category(missing, &target(), "imageMissing"); - - let mut wrong_mime = upstream_result(envelope.clone(), 1); - wrong_mime.raw["content"][1]["mimeType"] = Value::from("image/jpeg"); - assert_category(wrong_mime, &target(), "imageMime"); - - let mut duplicate = upstream_result_with_split_pngs(envelope.clone(), 2); - let repeated = duplicate.raw["content"][1].clone(); - duplicate.raw["content"] - .as_array_mut() - .unwrap() - .push(repeated); - assert_category(duplicate, &target(), "imageMultiplicity"); - - let oversized = vec![0_u8; 11 * 1024 * 1024 + 1]; - let error = decode_fast_snapshot( - &upstream_result_with_png(oversized, envelope.len(), 1), - &target(), - ) - .expect_err("oversized PNG"); - assert_eq!(error.details["category"], "png"); -} - -#[test] -fn schema_target_graph_and_resource_integrity_are_validated() { - let unsupported = mutate_envelope(|value| value["schemaVersion"] = Value::from(2)); - assert_category(upstream_result(unsupported, 1), &target(), "schemaVersion"); let wrong_target = FigmaTarget { file_key: "otherFileKey".to_owned(), ..target() }; assert_category( - upstream_result(complete_envelope(), 1), + text_upstream_result(&complete_envelope()), &wrong_target, "targetMismatch", ); @@ -265,45 +155,135 @@ fn schema_target_graph_and_resource_integrity_are_validated() { .push(duplicate); }); assert_category( - upstream_result(duplicate_node, 1), + text_upstream_result(&duplicate_node), &target(), "duplicateNode", ); + let missing_resource = mutate_envelope(|value| { + value["resources"]["variables"] = json!([]); + }); + assert_category( + text_upstream_result(&missing_resource), + &target(), + "resourceMissing", + ); + + // Corrupt the utf8Bytes counter *after* finalization (finalize_envelope's + // convergence loop would otherwise just recompute a correct value). + let mut bad_utf8_count: Value = serde_json::from_slice(&complete_envelope()).unwrap(); + bad_utf8_count["integrity"]["utf8Bytes"] = json!(1); + let bad_utf8_bytes = serde_json::to_vec(&bad_utf8_count).unwrap(); + assert_category( + text_upstream_result(&bad_utf8_bytes), + &target(), + "utf8Bytes", + ); +} + +#[test] +fn a_complete_single_page_envelope_still_requires_full_child_containment() { + // No cursor marker at all: treated as a single complete page, so a + // dangling child (referencing a node that was never sent) is rejected + // exactly like the pre-pagination behavior. let dangling_child = mutate_envelope(|value| { value["snapshot"]["nodes"][0]["fields"]["childrenIds"][0] = Value::from("9:9"); }); assert_category( - upstream_result(dangling_child, 1), + text_upstream_result(&dangling_child), &target(), "danglingChild", ); +} - let missing_resource = mutate_envelope(|value| { - value["resources"]["variables"] = json!([]); +#[test] +fn a_final_page_with_an_explicit_cursor_still_requires_full_child_containment() { + let dangling_child = mutate_envelope(|value| { + value["snapshot"]["nodes"][0]["fields"]["childrenIds"][0] = Value::from("9:9"); + push_cursor_marker(value, 0, 2, true, 2); }); assert_category( - upstream_result(missing_resource, 1), + text_upstream_result(&dangling_child), &target(), - "resourceMissing", + "danglingChild", ); } #[test] -fn descriptor_must_match_the_binary_envelope() { - let mut result = upstream_result(complete_envelope(), 2); - let descriptor_text = result.raw["content"][0]["text"].as_str().unwrap(); - let mut descriptor: Value = serde_json::from_str(descriptor_text).unwrap(); - descriptor["nodeCount"] = Value::from(99); - result.raw["content"][0]["text"] = Value::from(descriptor.to_string()); - - assert_category(result, &target(), "nodeCount"); +fn a_non_final_page_may_reference_children_that_have_not_arrived_yet() { + // node "1:2" (the second real node) is deliberately left out of this + // page; the root's childrenIds still references it. Because the page + // reports `complete: false`, this is expected — the child is assumed to + // arrive in a later round — and must not be rejected as dangling. + let first_page = mutate_envelope(|value| { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.truncate(1); + value["integrity"]["nodeCount"] = json!(1); + // No boundVariables/textStyleId left in this page, so no resources + // are referenced by it. + value["snapshot"]["nodes"][0]["fields"] + .as_object_mut() + .unwrap() + .remove("boundVariables"); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(0); + value["resources"]["variables"] = json!([]); + value["resources"]["styles"] = json!([]); + push_cursor_marker(value, 0, 1, false, 2); + }); + let result = text_upstream_result(&first_page); + + let decoded = decode_fast_snapshot(&result, &target()).expect("valid first page"); + assert_eq!(decoded.snapshot.nodes.len(), 2); // real node + cursor marker +} + +#[test] +fn a_first_page_that_omits_the_root_is_still_rejected() { + // The root must always be present on the first page (BFS visits it at + // index 0); a first page (offset == 0) that omits it is a real error. + let missing_root = mutate_envelope(|value| { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.remove(0); + value["integrity"]["nodeCount"] = json!(1); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(1); + value["resources"]["variables"] = json!([]); + push_cursor_marker(value, 0, 1, false, 2); + }); + assert_category(text_upstream_result(&missing_root), &target(), "nodeCount"); +} + +#[test] +fn a_continuation_page_may_omit_the_root_that_a_prior_page_already_sent() { + let second_page = mutate_envelope(|value| { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + nodes.remove(0); + value["integrity"]["nodeCount"] = json!(1); + value["integrity"]["variableRefCount"] = json!(0); + value["integrity"]["styleRefCount"] = json!(1); + value["resources"]["variables"] = json!([]); + push_cursor_marker(value, 1, 2, true, 2); + }); + let result = text_upstream_result(&second_page); + + let decoded = decode_fast_snapshot(&result, &target()).expect("valid continuation page"); + assert_eq!(decoded.snapshot.nodes.len(), 2); // real node + cursor marker +} + +#[test] +fn duplicate_cursor_markers_are_rejected() { + let bad = mutate_envelope(|value| { + push_cursor_marker(value, 0, 2, true, 2); + push_cursor_marker(value, 0, 2, true, 2); + value["integrity"]["nodeCount"] = json!(4); + }); + assert_category(text_upstream_result(&bad), &target(), "cursorMultiplicity"); } #[test] fn valid_fast_theme_envelope_round_trips_and_validates_counts() { let envelope = theme_envelope(); - let result = theme_upstream_result(envelope.clone(), 1); + let result = theme_text_upstream_result(&envelope); let decoded = decode_fast_theme(&result, "fileKey123").expect("valid fast theme"); @@ -322,38 +302,24 @@ fn valid_fast_theme_envelope_round_trips_and_validates_counts() { assert_eq!(decoded.resources.raw["styles"].as_array().unwrap().len(), 1); assert_eq!(decoded.resources.raw["localComplete"], true); assert_eq!(decoded.stats.raw_bytes, envelope.len()); + assert_eq!(decoded.stats.transport, "text"); - let mut bad = theme_upstream_result(envelope, 1); - let descriptor = bad.raw["content"][0]["text"].as_str().unwrap(); - let mut descriptor: Value = serde_json::from_str(descriptor).unwrap(); - descriptor["variableCount"] = json!(2); - bad.raw["content"][0]["text"] = json!(descriptor.to_string()); - let error = decode_fast_theme(&bad, "fileKey123").expect_err("count mismatch"); + let bad = mutate_theme_envelope(|value| value["integrity"]["variableCount"] = json!(2)); + let error = decode_fast_theme(&theme_text_upstream_result(&bad), "fileKey123") + .expect_err("count mismatch"); assert_eq!(error.details["category"], "variableCount"); } -#[test] -fn tagged_text_theme_envelope_round_trips_without_an_image() { - let envelope = theme_envelope(); - let result = text_upstream_result(&envelope); - - let decoded = decode_fast_theme(&result, "fileKey123").expect("valid text theme envelope"); - - assert_eq!(decoded.source_version.as_deref(), Some("v42")); - assert_eq!(decoded.stats.chunk_count, 0); - assert_eq!(decoded.stats.transport, "text"); -} - #[test] fn json_stringified_fast_theme_result_round_trips() { let envelope = theme_envelope(); - let mut result = theme_upstream_result(envelope, 1); + let mut result = theme_text_upstream_result(&envelope); result.raw = Value::String(result.raw.to_string()); let decoded = decode_fast_theme(&result, "fileKey123").expect("stringified official theme envelope"); - assert_eq!(decoded.stats.chunk_count, 1); + assert_eq!(decoded.stats.transport, "text"); assert_eq!(decoded.resources.raw["localComplete"], true); } @@ -418,7 +384,11 @@ fn complete_envelope() -> Vec { } fn theme_envelope() -> Vec { - finalize_envelope(json!({ + finalize_theme_envelope(theme_envelope_value()) +} + +fn theme_envelope_value() -> Value { + json!({ "kind": "devupFastThemeEnvelope", "schemaVersion": 1, "source": {"fileKey": "fileKey123", "version": "v42"}, @@ -440,7 +410,7 @@ fn theme_envelope() -> Vec { "unresolvedCount": 0, "utf8Bytes": 0 } - })) + }) } fn text_upstream_result(envelope: &[u8]) -> UpstreamResult { @@ -454,26 +424,36 @@ fn text_upstream_result(envelope: &[u8]) -> UpstreamResult { } } -fn theme_upstream_result(envelope: Vec, chunk_count: usize) -> UpstreamResult { - let png = envelope_png(&envelope, chunk_count); - let descriptor = json!({ - "kind": "devupFastThemeDescriptor", - "schemaVersion": 1, - "collectionCount": 1, - "variableCount": 1, - "styleCount": 1, - "unresolvedCount": 0, - "utf8Bytes": envelope.len(), - "chunkCount": chunk_count - }); - UpstreamResult { - raw: json!({ - "content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} - ] - }), - } +fn theme_text_upstream_result(envelope: &[u8]) -> UpstreamResult { + text_upstream_result(envelope) +} + +/// Appends the `__DEVUP_SNAPSHOT_CURSOR__` marker node every fast snapshot +/// script emits, mirroring the shape `take_snapshot_cursor` parses, and +/// updates `integrity.nodeCount` to include it (matching real script output, +/// which always counts the marker in the same `nodes` array it serializes). +fn push_cursor_marker( + value: &mut Value, + offset: u64, + next_offset: u64, + complete: bool, + total_nodes: u64, +) { + let nodes = value["snapshot"]["nodes"].as_array_mut().unwrap(); + let real_node_count = nodes.len() as u64; + nodes.push(json!({ + "id": "__DEVUP_SNAPSHOT_CURSOR__", + "type": "DEVUP_INTERNAL", + "fields": { + "offset": offset, + "nextOffset": next_offset, + "complete": complete, + "totalNodes": total_nodes + }, + "extra": {}, + "fieldErrors": {} + })); + value["integrity"]["nodeCount"] = json!(real_node_count + 1); } fn mutate_envelope(mutate: impl FnOnce(&mut Value)) -> Vec { @@ -482,7 +462,21 @@ fn mutate_envelope(mutate: impl FnOnce(&mut Value)) -> Vec { finalize_envelope(value) } -fn finalize_envelope(mut value: Value) -> Vec { +fn mutate_theme_envelope(mutate: impl FnOnce(&mut Value)) -> Vec { + let mut value = theme_envelope_value(); + mutate(&mut value); + finalize_theme_envelope(value) +} + +fn finalize_envelope(value: Value) -> Vec { + finalize_utf8_bytes(value) +} + +fn finalize_theme_envelope(value: Value) -> Vec { + finalize_utf8_bytes(value) +} + +fn finalize_utf8_bytes(mut value: Value) -> Vec { for _ in 0..8 { let bytes = serde_json::to_vec(&value).unwrap(); let length = bytes.len() as u64; @@ -498,158 +492,3 @@ fn assert_category(result: UpstreamResult, target: &FigmaTarget, expected: &str) let error = decode_fast_snapshot(&result, target).expect_err(expected); assert_eq!(error.details["category"], expected); } - -fn upstream_result(envelope: Vec, chunk_count: usize) -> UpstreamResult { - let png = envelope_png(&envelope, chunk_count); - upstream_result_with_png(png, envelope.len(), chunk_count) -} - -fn upstream_result_with_png( - png: Vec, - envelope_length: usize, - chunk_count: usize, -) -> UpstreamResult { - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:1", - "nodeCount": 2, - "variableRefCount": 1, - "styleRefCount": 1, - "utf8Bytes": envelope_length, - "chunkCount": chunk_count - }); - UpstreamResult { - raw: json!({ - "content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} - ] - }), - } -} - -fn upstream_result_with_split_pngs(envelope: Vec, chunk_count: usize) -> UpstreamResult { - assert!(chunk_count > 0 && chunk_count <= envelope.len()); - let per_chunk = envelope.len().div_ceil(chunk_count); - let payloads = envelope.chunks(per_chunk).collect::>(); - assert_eq!(payloads.len(), chunk_count); - let mut content = vec![json!({ - "type": "text", - "text": json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:1", - "nodeCount": 2, - "variableRefCount": 1, - "styleRefCount": 1, - "utf8Bytes": envelope.len(), - "chunkCount": chunk_count - }).to_string() - })]; - for (sequence, payload) in payloads.into_iter().enumerate() { - let png = envelope_png_for_chunk(payload, sequence, chunk_count); - content.push(json!({ - "type": "image", - "data": STANDARD.encode(png), - "mimeType": "image/png" - })); - } - UpstreamResult { - raw: json!({"content": content}), - } -} - -fn envelope_png(envelope: &[u8], chunk_count: usize) -> Vec { - assert!(chunk_count > 0 && chunk_count <= envelope.len()); - let order = (0..chunk_count).collect::>(); - envelope_png_with_order(envelope, &order) -} - -fn envelope_png_with_ihdr(envelope: &[u8], ihdr: &[u8; 13]) -> Vec { - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", ihdr); - let mut data = Vec::with_capacity(envelope.len() + 8); - data.extend_from_slice(&0_u32.to_be_bytes()); - data.extend_from_slice(&1_u32.to_be_bytes()); - data.extend_from_slice(envelope); - push_chunk(&mut png, b"duVp", &data); - push_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_chunk(&mut png, b"IEND", &[]); - png -} - -fn envelope_png_for_chunk(payload: &[u8], sequence: usize, total: usize) -> Vec { - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut data = Vec::with_capacity(payload.len() + 8); - data.extend_from_slice(&(sequence as u32).to_be_bytes()); - data.extend_from_slice(&(total as u32).to_be_bytes()); - data.extend_from_slice(payload); - push_chunk(&mut png, b"duVp", &data); - push_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_chunk(&mut png, b"IEND", &[]); - png -} - -fn envelope_png_with_order(envelope: &[u8], order: &[usize]) -> Vec { - let chunk_count = order.len(); - assert!(chunk_count > 0 && chunk_count <= envelope.len()); - let mut png = PNG_SIGNATURE.to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - - let per_chunk = envelope.len().div_ceil(chunk_count); - let payloads = envelope.chunks(per_chunk).collect::>(); - assert_eq!(payloads.len(), chunk_count); - for &sequence in order { - let payload = payloads[sequence]; - let mut data = Vec::with_capacity(payload.len() + 8); - data.extend_from_slice(&(sequence as u32).to_be_bytes()); - data.extend_from_slice(&(chunk_count as u32).to_be_bytes()); - data.extend_from_slice(payload); - push_chunk(&mut png, b"duVp", &data); - } - - push_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_chunk(&mut png, b"IEND", &[]); - png -} - -fn push_chunk(output: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(chunk_type); - output.extend_from_slice(data); - let mut crc_input = Vec::with_capacity(4 + data.len()); - crc_input.extend_from_slice(chunk_type); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} diff --git a/crates/devup-mcp-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index 214ec2a..21df545 100644 --- a/crates/devup-mcp-figma/tests/upstream_contract.rs +++ b/crates/devup-mcp-figma/tests/upstream_contract.rs @@ -130,21 +130,30 @@ fn asset_export_uses_only_compiled_read_only_export_settings() { } #[test] -fn snapshot_manifest_covers_current_official_node_properties() { +fn snapshot_manifest_covers_fields_the_devup_ui_converter_actually_reads() { + // The manifest is scoped to devup-ui codegen consumption (verified + // against `crates/devup-mcp-devup-ui`), not the full official Plugin API + // surface — `maskType`, `detachedInfo`, `exposedInstances` and + // `isExposedInstance` were removed because nothing reads them. let call = ReadToolCall::snapshot("file-key", "1:2", BuiltinScript::NodeSnapshot); let code = call.arguments()["code"].as_str().unwrap().to_owned(); for property in [ - "\"maskType\"", - "\"overflowDirection\"", "\"primaryAxisAlignItems\"", "\"componentPropertyReferences\"", - "\"detachedInfo\"", - "\"exposedInstances\"", - "\"isExposedInstance\"", + "\"layoutSizingHorizontal\"", + "\"boundVariables\"", + "\"strokeStyleId\"", + "\"textStyleId\"", ] { assert!(code.contains(property), "manifest omitted {property}"); } + for property in ["\"maskType\"", "\"detachedInfo\"", "\"exposedInstances\""] { + assert!( + !code.contains(property), + "manifest still carries unused {property}" + ); + } } #[test] @@ -254,12 +263,22 @@ fn multi_root_fast_snapshot_embeds_only_validated_root_ids() { let code = call.arguments()["code"].as_str().unwrap().to_owned(); assert_eq!(call.tool_name(), "use_figma"); - assert_eq!(call.arguments()["nodeId"], "4217:7743"); + // The official `use_figma` schema forbids a `nodeId` argument + // (`additionalProperties: false`); the target node is tracked outside + // `arguments` (`PlannedCall::expected_node_id` / `HandoffCall::node_id`). + assert!(!call.arguments().contains_key("nodeId")); + assert!( + call.arguments()["description"] + .as_str() + .unwrap() + .contains("4217:7743") + ); assert!(code.contains("[\"10:3\",\"10:2\"]")); assert!(code.contains("requestedRootIds")); assert!(code.contains("rootIds: roots.map")); assert!(code.contains("getStyledTextSegments(textSegmentManifest)")); - assert!(code.contains("devupFastSnapshotDescriptor")); + assert!(code.contains("devupFastSnapshotEnvelope")); + assert!(!code.contains("figma.io.write")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); } @@ -316,13 +335,19 @@ fn used_resources_use_exact_ids_without_file_catalog_or_consumers() { } #[test] -fn fast_snapshot_is_lossless_bounded_and_read_only() { +fn fast_snapshot_is_paginated_manifest_scoped_and_read_only() { let call = ReadToolCall::fast_snapshot("file-key", "1:2"); let code = call.arguments()["code"].as_str().unwrap().to_owned(); assert_eq!(call.tool_name(), "use_figma"); assert!(code.contains("figma.getNodeByIdAsync")); - assert!(code.contains("if (name in value) names.add(name)")); + // Item A: node property collection no longer walks the prototype chain + // (that only remains for variable/style *resource* serialization, which + // has no manifest) and never buckets unlisted fields into "extra" — only + // the checked-in manifest is ever collected for a node. + assert!(code.contains("for (const name of manifest)")); + assert!(!code.contains("(manifestSet.has(name) ? fields : extra)")); + assert!(!code.contains("const manifestSet = new Set(manifest)")); assert!(code.contains("getStyledTextSegments(textSegmentManifest)")); for field in [ "strokeTopWeight", @@ -338,24 +363,28 @@ fn fast_snapshot_is_lossless_bounded_and_read_only() { assert!(code.contains("Promise.all([...variableJobs, ...styleJobs])")); assert!(code.contains("usedVariableIds")); assert!(code.contains("usedStyleIds")); - assert!(code.contains("duVp")); - assert!(code.contains("figma.io.write")); - assert!(code.contains("devup-fast-snapshot-${sequence + 1}-of-${chunkCount}.png")); - assert!(code.contains("devupFastSnapshotDescriptor")); + // Item B: PNG-chunked binary transport is gone entirely — text only, + // dynamically byte-budgeted and cursor-paginated like the legacy path. + assert!(!code.contains("duVp")); + assert!(!code.contains("figma.io.write")); + assert!(!code.contains("devup-fast-snapshot")); + assert!(!code.contains("devupFastSnapshotDescriptor")); + assert!(!code.contains("pngChunk")); + assert!(!code.contains("crc32")); + assert!(code.contains("maxPayloadBytes")); + assert!(code.contains("__DEVUP_SNAPSHOT_CURSOR__")); + assert!(code.contains("nextOffset")); assert!(code.contains("MAX_ENVELOPE_BYTES")); assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); assert!(code.contains("devupFastSnapshotEnvelope")); assert!(code.contains("DEVUP_TARGET_IS_SECTION")); assert!(code.contains("0xfffd")); - assert!(!code.contains("maxPayloadBytes")); - assert!(!code.contains("maxFieldBytes")); assert!(!code.contains("DEVUP_FIELD_VALUE_TRUNCATED")); assert!(!code.contains("MAX_INLINE_FIELD_BYTES")); assert!(!code.contains("devupLargeValueDescriptor")); assert!(!code.contains("$largeValue")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); - assert_eq!(code.matches("figma.io.write(").count(), 1); } #[test] @@ -364,8 +393,12 @@ fn fast_snapshot_resolves_every_compiled_placeholder_for_the_requested_root() { let code = call.arguments()["code"].as_str().unwrap().to_owned(); assert!(code.contains("const requestedRootIds = [\"3879:35518\"]")); + // `__DEVUP_SNAPSHOT_CURSOR__` is a real runtime node-ID sentinel (same + // one the legacy cursor snapshot uses), not a template placeholder — it + // is never meant to be substituted, so it is excluded from this check. + let without_cursor_sentinel = code.replace("__DEVUP_SNAPSHOT_CURSOR__", ""); assert!( - !code.contains("__DEVUP_"), + !without_cursor_sentinel.contains("__DEVUP_"), "compiled fast snapshot leaked an unresolved template placeholder" ); } @@ -410,9 +443,13 @@ fn fast_theme_collects_complete_local_theme_and_used_remote_resources_read_only( assert!(code.contains(read), "missing theme read {read}"); } assert!(code.contains("usedRemoteVariables")); - assert!(code.contains("devupFastThemeDescriptor")); - assert!(code.contains("devup-fast-theme-${sequence + 1}-of-${chunkCount}.png")); - assert!(code.contains("duVp")); + // No binary transport exists any more — a theme that doesn't fit as text + // throws and the caller falls back to the legacy per-resource path. + assert!(!code.contains("devupFastThemeDescriptor")); + assert!(!code.contains("devup-fast-theme")); + assert!(!code.contains("duVp")); + assert!(!code.contains("figma.io.write")); + assert!(!code.contains("pngChunk")); assert!(code.contains("MAX_ENVELOPE_BYTES")); assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); assert!(code.contains("devupFastThemeEnvelope")); diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index 0069f55..9da08c7 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -70,6 +70,12 @@ pub struct HandoffCall { pub server: &'static str, pub tool: &'static str, pub arguments: Value, + /// The Figma node this call targets, tracked outside `arguments` because + /// the official `use_figma` schema forbids a `nodeId` argument + /// (`additionalProperties: false`). Absent for calls with no single + /// target node (e.g. the file-wide page catalog). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, } #[derive(Debug)] @@ -228,6 +234,7 @@ impl HandoffStore { server: "figma", tool: planned.call.tool_name(), arguments: Value::Object(planned.call.arguments()), + node_id: planned.expected_node_id.clone(), }; session.pending.insert(call_id, (planned.id, handoff_call)); } diff --git a/crates/devup-mcp/tests/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index 3dea9fe..636fd75 100644 --- a/crates/devup-mcp/tests/composite_export.rs +++ b/crates/devup-mcp/tests/composite_export.rs @@ -765,6 +765,7 @@ async fn strict_tsx_export_rejects_lossy_projection() -> anyhow::Result<()> { fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "1:2"}, "snapshot": { @@ -835,36 +836,14 @@ fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; + let _ = envelope_bytes; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut payload = Vec::with_capacity(envelope_bytes.len() + 8); - payload.extend_from_slice(&0_u32.to_be_bytes()); - payload.extend_from_slice(&1_u32.to_be_bytes()); - payload.extend_from_slice(&envelope_bytes); - push_png_chunk(&mut png, b"duVp", &payload); - push_png_chunk( - &mut png, - b"IDAT", - &[ - 0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1, - ], - ); - push_png_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", - "schemaVersion": 1, - "rootId": "1:2", - "nodeCount": 1, - "variableRefCount": 1, - "styleRefCount": 0, - "utf8Bytes": envelope_bytes.len(), - "chunkCount": 1 - }); + // No binary transport exists any more: fast snapshots are always plain + // text (`devupFastSnapshotEnvelope`). Omitting the cursor marker node is + // treated by the decoder as a single, already-complete page. UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } @@ -892,24 +871,3 @@ fn asset_export_result( fn reference_png_base64() -> &'static str { "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" } - -fn push_png_chunk(output: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(chunk_type); - output.extend_from_slice(data); - let mut crc_input = Vec::with_capacity(4 + data.len()); - crc_input.extend_from_slice(chunk_type); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index ca30575..d319a94 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -4,7 +4,6 @@ use std::sync::{ }; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp::server::{DevupAuth, DevupServer, Services}; use devup_mcp_figma::{ AuthStatus, BuiltinScript, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, @@ -272,6 +271,7 @@ fn multi_root_envelope(root_ids: &[String]) -> UpstreamResult { }) .collect::>(); let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": {"fileKey": "FileKey123", "rootId": "10:1"}, "snapshot": { @@ -285,55 +285,19 @@ fn multi_root_envelope(root_ids: &[String]) -> UpstreamResult { }, "integrity": {"nodeCount": root_ids.len(), "variableRefCount": 0, "styleRefCount": 0, "utf8Bytes": 0} }); - let bytes = loop { + let _bytes = loop { let bytes = serde_json::to_vec(&envelope).unwrap(); if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { break bytes; } envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); }; - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - push_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); - let mut chunk = Vec::with_capacity(bytes.len() + 8); - chunk.extend_from_slice(&0_u32.to_be_bytes()); - chunk.extend_from_slice(&1_u32.to_be_bytes()); - chunk.extend_from_slice(&bytes); - push_chunk(&mut png, b"duVp", &chunk); - push_chunk( - &mut png, - b"IDAT", - &[0x78, 1, 1, 5, 0, 0xfa, 0xff, 0, 0, 0, 0, 0, 5, 0, 1], - ); - push_chunk(&mut png, b"IEND", &[]); - let descriptor = json!({ - "kind": "devupFastSnapshotDescriptor", "schemaVersion": 1, "rootId": "10:1", - "nodeCount": root_ids.len(), "variableRefCount": 0, "styleRefCount": 0, - "utf8Bytes": bytes.len(), "chunkCount": 1 - }); + // No binary transport exists any more: fast snapshots are always plain + // text (`devupFastSnapshotEnvelope`). Omitting the cursor marker node is + // treated by the decoder as a single, already-complete page. UpstreamResult { raw: json!({"content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} + {"type": "text", "text": envelope.to_string()} ]}), } } - -fn push_chunk(output: &mut Vec, kind: &[u8; 4], data: &[u8]) { - output.extend_from_slice(&(data.len() as u32).to_be_bytes()); - output.extend_from_slice(kind); - output.extend_from_slice(data); - let mut crc_input = kind.to_vec(); - crc_input.extend_from_slice(data); - output.extend_from_slice(&crc32(&crc_input).to_be_bytes()); -} - -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = u32::MAX; - for byte in bytes { - crc ^= u32::from(*byte); - for _ in 0..8 { - crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); - } - } - !crc -} diff --git a/crates/devup-mcp/tests/source_orchestration.rs b/crates/devup-mcp/tests/source_orchestration.rs index 88a761e..06376e2 100644 --- a/crates/devup-mcp/tests/source_orchestration.rs +++ b/crates/devup-mcp/tests/source_orchestration.rs @@ -288,11 +288,28 @@ async fn auto_disconnected_returns_handoff_without_starting_oauth() -> anyhow::R assert_eq!(output["status"], "needs_figma"); assert_eq!(output["resumeTool"], "devup_figma_continue"); assert_eq!(output["calls"][0]["tool"], "use_figma"); + // The real `use_figma` schema is `{ fileKey, code, description, skillNames? }` + // with `additionalProperties: false` — `nodeId` must never be an argument + // key, and `description` is required. + let arguments = output["calls"][0]["arguments"] + .as_object() + .expect("use_figma arguments object"); + assert!(!arguments.contains_key("nodeId")); + assert!(arguments["description"].as_str().unwrap().contains("node")); + assert_eq!(output["calls"][0]["nodeId"], "1:2"); assert!( - output["calls"][0]["arguments"]["code"] + arguments["code"] .as_str() .unwrap() - .contains("devupFastSnapshotDescriptor") + .contains("devupFastSnapshotEnvelope") + ); + // The old PNG-chunked binary transport was proven not to survive real + // hosts and has been removed entirely. + assert!( + !arguments["code"] + .as_str() + .unwrap() + .contains("figma.io.write") ); assert!(output["expiresAt"].as_str().unwrap().contains('T')); assert!(output["expiresAt"].as_str().unwrap().ends_with('Z')); @@ -598,9 +615,12 @@ async fn public_continuation_finishes_a_multi_call_host_collection() -> anyhow:: assert_eq!(after_fast["calls"][0]["tool"], "get_metadata"); assert_eq!(after_fast["collection"]["figmaToolCalls"], 2); assert_eq!(after_fast["collection"]["fallbackUsed"], true); + // `snapshot_result()` is a bare SnapshotChunk-shaped result, not tagged + // with `"kind": "devupFastSnapshotEnvelope"`, so decoding never finds a + // fast text envelope at all (no PNG fallback exists any more either). assert_eq!( after_fast["collection"]["fallbackReason"], - "descriptorMissing" + "textEnvelopeMissing" ); let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); let after_metadata = client From fb73463b0e08fb2b9452e6b3c606cc91d799a133 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 11:15:06 +0900 Subject: [PATCH 14/69] docs: describe manifest-scoped fast snapshot and text pagination Replace the outdated PNG-chunked-envelope description with the actual text-only, optionally-paginated transport and the manifest trim from the 6th-round brief. No binary transport exists any more. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f30f1c5..49b7c34 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ Section 링크는 전체 subtree를 직접 변환하지 않습니다. `selection `sourcePolicy`는 `auto`, `direct`, `host` 중 하나입니다. `needs_figma` 응답의 read-only call을 host의 공식 Figma MCP에서 실행한 뒤 원본 result를 `devup_figma_continue`의 `sessionId`, `callId`, `result`로 전달하면 동일한 Rust collector가 이어서 처리합니다. session은 메모리에만 최대 10분 유지되며 완료·오류·만료 시 제거됩니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다. -정확한 node 링크의 UI 변환은 우선 하나의 공식 `use_figma` 호출 안에서 subtree 전체와 실제 사용 리소스를 수집합니다. JSON envelope를 512 KiB 단위로 나누고 각 조각을 CRC가 있는 1×1 PNG에 담아 MCP 응답 크기 제한을 피하며, Rust는 MIME·base64·PNG 구조·청크 순서·schema·대상 ID·node graph·리소스 참조를 모두 검증한 뒤에만 결과를 채택합니다. 한 항목이라도 불일치하면 fast 결과 전체를 버리고 기존 cursor 수집을 0부터 재시작합니다. Section multi-root에서는 성공한 root와 resource는 그대로 보존하고 실패하거나 상한을 넘은 root만 legacy로 다시 수집한 뒤 원래 시각 순서로 합칩니다. direct upstream은 연결과 read-only tool catalog를 한 session에서 재사용하고 30초 TTL, 연결 종료 또는 transport 오류 때만 재연결·재검증합니다. 결과의 `stats`에는 `figmaToolCalls`, `transport`, `fallbackUsed`, node/variable/style 수와 byte/청크 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다. +정확한 node 링크의 UI 변환은 하나 이상의 공식 `use_figma` 호출 안에서 subtree와 실제 사용 리소스를 수집합니다. 수집 스크립트는 checked-in manifest(devup-ui 변환기가 실제로 읽는 필드만)만 확인하고 — 프로토타입 체인 전체를 훑거나 미분류 필드를 `extra`에 담지 않습니다 — `null`/빈 배열/미바인딩 style ID 같은 기본값은 봉투에서 생략합니다. 결과는 항상 텍스트(`devupFastSnapshotEnvelope`)이며 PNG 같은 바이너리 transport는 없습니다. 한 subtree가 15KB 텍스트 한도를 넘으면 같은 스크립트를 `offset`을 옮겨 다시 호출하는 방식으로 텍스트 페이지네이션합니다 — 각 라운드는 그 라운드가 보낸 node에서만 리소스를 스캔해 자기 완결적이며, Rust가 여러 라운드의 node와 리소스를 병합합니다. Rust는 schema·대상 ID·node graph·리소스 참조·(페이지 중이 아닐 때의) 자식 완전성을 모두 검증한 뒤에만 결과를 채택합니다. 한 항목이라도 불일치하면 fast 결과 전체를 버리고 기존 cursor 수집을 0부터 재시작합니다. Section multi-root에서는 성공한 root와 resource는 그대로 보존하고 실패하거나 상한을 넘은 root만 legacy로 다시 수집한 뒤 원래 시각 순서로 합칩니다. direct upstream은 연결과 read-only tool catalog를 한 session에서 재사용하고 30초 TTL, 연결 종료 또는 transport 오류 때만 재연결·재검증합니다. 결과의 `stats`에는 `figmaToolCalls`, `transport`(`text` | `text-paginated` | `legacy-cursor`), `fallbackUsed`, node/variable/style 수와 byte 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다. 완전성 등급은 다음과 같습니다. @@ -301,7 +301,7 @@ Section 링크는 전체 subtree를 직접 변환하지 않습니다. `selection ## 읽기 전용·개인정보 보호 - upstream 호출은 `get_metadata`, `get_variable_defs`, `get_design_context`, `get_code_connect_map`, `get_screenshot`과 내장된 read-only `use_figma` script로 닫혀 있습니다. -- 사용자 입력 JavaScript를 받지 않으며 Figma document mutation API를 호출하지 않습니다. `figma.io.write`는 공식 MCP 응답으로 검증 가능한 1×1 PNG를 반환하는 transport에만 사용하며 Figma 파일을 변경하지 않습니다. +- 사용자 입력 JavaScript를 받지 않으며 Figma document mutation API를 호출하지 않습니다. `figma.io.write`는 asset export(`devup_figma_export`의 `assetRequests`)에만 read-only로 사용하며 Figma 파일을 변경하지 않습니다. fast snapshot/theme envelope는 항상 텍스트로만 반환되며 바이너리 transport를 쓰지 않습니다. - stdout에는 MCP frame만 출력하고 trace는 stderr로 보냅니다. - access token, refresh token, OAuth code, PKCE verifier는 Debug, trace와 MCP error에 포함하지 않습니다. - Figma snapshot과 screenshot을 기본적으로 디스크에 저장하지 않습니다. From 52d0fe83e995200a867cbe56388159fc47cba7b2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 12:03:36 +0900 Subject: [PATCH 15/69] fix(figma): emit offset on the fast snapshot cursor marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by driving the freshly built release binary end to end against the real Figma node 3997:47467 (file 85CgSws3o5XsLv7aAwWJyS) over stdio MCP: every fast snapshot was silently downgraded to legacy cursor collection with fallbackReason "cursorShape". nvelope.rs::peek_page_cursor reads offset off the __DEVUP_SNAPSHOT_CURSOR__ marker to tell a first page from a continuation page, but fast_snapshot.js only wrote nextOffset/complete/totalNodes on the marker - offset existed solely on the top-level pagination object. The unit tests missed it because the hand-built test fixture did write offset, so the fixture and the real script had diverged. - fast_snapshot.js now emits { offset, nextOffset, complete, totalNodes } on the marker. - upstream_contract.rs pins the marker's literal emitted shape, so a fixture/script divergence fails the build instead of degrading silently. - envelope.rs gains a regression test for a marker missing offset. Re-verified end to end after the fix: status "complete", one Figma tool call, transport "text", fallbackUsed false, quality acquisition=complete/projection=exact, fidelity 10000bp on every axis, zero diagnostics. --- .../tests/fixtures/manifest-trim-golden.json | 544 ++++++++++++++++++ .../tests/manifest_trim_golden.rs | 130 +++++ .../src/scripts/fast_snapshot.js | 6 +- crates/devup-mcp-figma/tests/envelope.rs | 15 + .../tests/upstream_contract.rs | 6 +- 5 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json create mode 100644 crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json b/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json new file mode 100644 index 0000000..598ea8d --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/manifest-trim-golden.json @@ -0,0 +1,544 @@ +{ + "note": "Both node sets were collected read-only from the same live Figma node (file 85CgSws3o5XsLv7aAwWJyS, node 3997:47467) in the same session on 2026-09-03. `legacy` uses the pre-trim collection semantics (133-field manifest + prototype-chain walk into `extra`, no default omission); `trimmed` uses the shipped 77-field manifest with default omission. Only design node/text values are stored - no tokens, headers or account data.", + "fileKey": "85CgSws3o5XsLv7aAwWJyS", + "rootId": "3997:47467", + "legacyUtf8Bytes": 23311, + "trimmedUtf8Bytes": 3862, + "legacy": [ + { + "id": "3997:47467", + "type": "FRAME", + "fields": { + "parentId": "4279:7804", + "childrenIds": ["3997:47468"], + "absoluteBoundingBox": { "height": 97, "width": 9605, "x": 14422, "y": 18313 }, + "absoluteRenderBounds": { "height": 97, "width": 9605, "x": 14422, "y": 18313 }, + "annotations": [], + "attachedConnectors": [], + "backgroundStyleId": "", + "backgrounds": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 0.4038458466529846, "g": 0.3634612560272217, "r": 0 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "blendMode": "PASS_THROUGH", + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": {}, + "clipsContent": false, + "componentPropertyReferences": null, + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "cornerRadius": 0, + "cornerSmoothing": 0, + "counterAxisAlignContent": "AUTO", + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "dashPattern": [], + "detachedInfo": null, + "effectStyleId": "", + "effects": [], + "expanded": false, + "explicitVariableModes": {}, + "exportSettings": [], + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 0.4038458466529846, "g": 0.3634612560272217, "r": 0 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "gridColumnAnchorIndex": -1, + "gridColumnCount": 0, + "gridColumnGap": 0, + "gridColumnSpan": 1, + "gridRowAnchorIndex": -1, + "gridRowCount": 0, + "gridRowGap": 0, + "gridRowSpan": 1, + "gridStyleId": "", + "guides": [], + "height": 97, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": false, + "isMask": false, + "itemReverseZIndex": false, + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrids": [], + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "layoutWrap": "NO_WRAP", + "locked": false, + "maskType": "ALPHA", + "maxHeight": null, + "maxWidth": null, + "minHeight": null, + "minWidth": null, + "name": "[FR-03~06] 체험하기", + "numberOfFixedChildren": 0, + "opacity": 1, + "overflowDirection": "NONE", + "overlayBackground": { "type": "NONE" }, + "overlayBackgroundInteraction": "NONE", + "overlayPositionType": "CENTER", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "reactions": [], + "relativeTransform": [[1, 0, 93], [0, 1, 178]], + "removed": false, + "resolvedVariableModes": {}, + "rotation": 0, + "strokeAlign": "INSIDE", + "strokeBottomWeight": 1, + "strokeCap": "NONE", + "strokeJoin": "MITER", + "strokeLeftWeight": 1, + "strokeMiterLimit": 4, + "strokeRightWeight": 1, + "strokeStyleId": "", + "strokeTopWeight": 1, + "strokeWeight": 1, + "strokes": [], + "stuckNodes": [], + "targetAspectRatio": null, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 9605, + "x": 93, + "y": 178 + }, + "extra": { + "absoluteTransform": [[1, 0, 14422], [0, 1, 18313]], + "animationStyles": [], + "animations": {}, + "availableInferredVariables": {}, + "complexStrokeProperties": { "type": "BASIC" }, + "constrainProportions": false, + "counterAxisSpacing": 0, + "fillGeometry": [ + { "data": "M0 0 L9605 0 L9605 97 L0 97 L0 0 Z", "windingRule": "NONZERO" } + ], + "gridAutoTracks": "NONE", + "gridChildHorizontalAlign": "AUTO", + "gridChildVerticalAlign": "AUTO", + "gridColumnSizes": [], + "gridColumnSizingCSS": "", + "gridItemsPositioning": "MANUAL", + "gridRowSizes": [], + "gridRowSizingCSS": "", + "horizontalPadding": 20, + "inferredVariables": {}, + "manualKeyframeTracks": {}, + "node": { "$nodeId": "3997:47467", "$nodeType": "FRAME" }, + "placeholder": false, + "playbackSettings": { "autoplay": true, "loop": true, "muted": false }, + "primaryAxisSizingMode": "FIXED", + "strokeGeometry": [], + "strokesIncludedInLayout": false, + "timelines": [{ "duration": 2, "id": "3997:47467" }], + "variableConsumptionMap": {}, + "variableWidthStrokeProperties": { + "variableWidthPoints": [], + "widthProfile": "UNIFORM" + }, + "verticalPadding": 20 + }, + "fieldErrors": { + "devStatus": "in get_devStatus: \"devStatus\" is not a supported API", + "isClip": "in get_isClip: \"isClip\" is not a supported API", + "isClipBackedComponentInstance": "in get_isClipBackedComponentInstance: \"isClipBackedComponentInstance\" is not a supported API", + "rotationOrigin": "in get_rotationOrigin: \"rotationOrigin\" is not a supported API", + "widgetHoverStyle": "in get_widgetHoverStyle: \"widgetHoverStyle\" is not a supported API" + } + }, + { + "id": "3997:47468", + "type": "TEXT", + "fields": { + "parentId": "3997:47467", + "childrenIds": [], + "absoluteBoundingBox": { "height": 57, "width": 450, "x": 14442, "y": 18333 }, + "absoluteRenderBounds": { + "height": 48.28125, + "width": 440.34375, + "x": 14447.0625, + "y": 18340.28125 + }, + "annotations": [], + "attachedConnectors": [], + "blendMode": "PASS_THROUGH", + "boundVariables": {}, + "characters": "[FR-03~06] 체험하기", + "componentPropertyReferences": null, + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "dashPattern": [], + "detachedInfo": null, + "effectStyleId": "", + "effects": [], + "explicitVariableModes": {}, + "exportSettings": [], + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "gridColumnAnchorIndex": -1, + "gridColumnSpan": 1, + "gridRowAnchorIndex": -1, + "gridRowSpan": 1, + "height": 57, + "hyperlink": null, + "isAsset": false, + "isMask": false, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "locked": false, + "maskType": "ALPHA", + "maxHeight": null, + "maxWidth": null, + "minHeight": null, + "minWidth": null, + "name": "[FR-03~06] 체험하기", + "opacity": 1, + "paragraphIndent": 0, + "paragraphSpacing": 0, + "reactions": [], + "relativeTransform": [[1, 0, 20], [0, 1, 20]], + "removed": false, + "resolvedVariableModes": {}, + "rotation": 0, + "strokeAlign": "OUTSIDE", + "strokeCap": "NONE", + "strokeJoin": "MITER", + "strokeMiterLimit": 4, + "strokeStyleId": "", + "strokeWeight": 1, + "strokes": [], + "stuckNodes": [], + "targetAspectRatio": null, + "textAlignHorizontal": "CENTER", + "textAlignVertical": "TOP", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "textStyleId": "", + "visible": true, + "width": 450, + "x": 20, + "y": 20, + "styledTextSegments": [ + { + "characters": "[FR-03~06] 체험하기", + "end": 15, + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "listOptions": { "type": "NONE" }, + "start": 0, + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "textStyleId": "" + } + ] + }, + "extra": { + "absoluteTransform": [[1, 0, 14442], [0, 1, 18333]], + "animationStyles": [], + "animations": {}, + "autoRename": true, + "availableInferredVariables": { + "fills": [ + [ + { "id": "VariableID:1:1000", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:1:1006", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:68:6122", "type": "VARIABLE_ALIAS" } + ] + ] + }, + "canUpgradeToNativeBidiSupport": false, + "complexStrokeProperties": { "type": "BASIC" }, + "constrainProportions": false, + "fontWeight": 700, + "gridChildHorizontalAlign": "AUTO", + "gridChildVerticalAlign": "AUTO", + "hangingList": false, + "hangingPunctuation": false, + "hasMissingFont": true, + "inferredVariables": { + "fills": [ + [ + { + "id": "VariableID:b1ac3f2d99f4f584780a1b02b0bdf70873612d7d/2324:176", + "type": "VARIABLE_ALIAS" + }, + { "id": "VariableID:1:1000", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:1:1006", "type": "VARIABLE_ALIAS" }, + { "id": "VariableID:68:6122", "type": "VARIABLE_ALIAS" }, + { + "id": "VariableID:fc5c8b3838fdbe6abf30bcbc881a5a6c2da71856/155:1", + "type": "VARIABLE_ALIAS" + }, + { + "id": "VariableID:767f04d30caf20c0c878e8546732b78b74fb70e1/156:471", + "type": "VARIABLE_ALIAS" + }, + { + "id": "VariableID:a01f35bf66e644ecb6fb9343dbef6146ccf77918/155:11", + "type": "VARIABLE_ALIAS" + } + ] + ] + }, + "leadingTrim": "NONE", + "listSpacing": 0, + "manualKeyframeTracks": {}, + "maxLines": null, + "node": { "$nodeId": "3997:47468", "$nodeType": "TEXT" }, + "openTypeFeatures": {}, + "placeholder": false, + "playbackSettings": { "autoplay": true, "loop": true, "muted": false }, + "strokeGeometry": [], + "textDecorationColor": null, + "textDecorationOffset": null, + "textDecorationSkipInk": null, + "textDecorationStyle": null, + "textDecorationThickness": null, + "textTruncation": "DISABLED", + "textWrapStyle": "AUTO", + "timelines": [{ "duration": 2, "id": "3997:47467" }], + "variableConsumptionMap": {}, + "variableWidthStrokeProperties": { + "variableWidthPoints": [], + "widthProfile": "UNIFORM" + } + }, + "fieldErrors": { + "isClip": "in get_isClip: \"isClip\" is not a supported API", + "isClipBackedComponentInstance": "in get_isClipBackedComponentInstance: \"isClipBackedComponentInstance\" is not a supported API", + "rotationOrigin": "in get_rotationOrigin: \"rotationOrigin\" is not a supported API", + "widgetHoverStyle": "in get_widgetHoverStyle: \"widgetHoverStyle\" is not a supported API" + } + } + ], + "trimmed": [ + { + "id": "3997:47467", + "type": "FRAME", + "fields": { + "parentId": "4279:7804", + "childrenIds": ["3997:47468"], + "absoluteBoundingBox": { "height": 97, "width": 9605, "x": 14422, "y": 18313 }, + "blendMode": "PASS_THROUGH", + "bottomLeftRadius": 0, + "bottomRightRadius": 0, + "boundVariables": {}, + "clipsContent": false, + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "cornerRadius": 0, + "counterAxisAlignItems": "CENTER", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 0.4038458466529846, "g": 0.3634612560272217, "r": 0 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "gridColumnAnchorIndex": -1, + "gridColumnCount": 0, + "gridColumnGap": 0, + "gridRowAnchorIndex": -1, + "gridRowCount": 0, + "gridRowGap": 0, + "height": 97, + "inferredAutoLayout": { + "counterAxisAlignItems": "CENTER", + "counterAxisSizingMode": "AUTO", + "itemSpacing": 10, + "layoutAlign": "INHERIT", + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "primaryAxisSizingMode": "FIXED" + }, + "isAsset": false, + "isMask": false, + "itemSpacing": 10, + "layoutGrow": 0, + "layoutMode": "HORIZONTAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "name": "[FR-03~06] 체험하기", + "opacity": 1, + "paddingBottom": 20, + "paddingLeft": 20, + "paddingRight": 20, + "paddingTop": 20, + "primaryAxisAlignItems": "MIN", + "rotation": 0, + "strokeAlign": "INSIDE", + "strokeBottomWeight": 1, + "strokeLeftWeight": 1, + "strokeRightWeight": 1, + "strokeTopWeight": 1, + "strokeWeight": 1, + "topLeftRadius": 0, + "topRightRadius": 0, + "visible": true, + "width": 9605, + "x": 93, + "y": 178 + }, + "extra": {}, + "fieldErrors": {} + }, + { + "id": "3997:47468", + "type": "TEXT", + "fields": { + "parentId": "3997:47467", + "childrenIds": [], + "absoluteBoundingBox": { "height": 57, "width": 450, "x": 14442, "y": 18333 }, + "blendMode": "PASS_THROUGH", + "boundVariables": {}, + "characters": "[FR-03~06] 체험하기", + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "gridColumnAnchorIndex": -1, + "gridRowAnchorIndex": -1, + "height": 57, + "isAsset": false, + "isMask": false, + "layoutGrow": 0, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "name": "[FR-03~06] 체험하기", + "opacity": 1, + "rotation": 0, + "strokeAlign": "OUTSIDE", + "strokeWeight": 1, + "textAlignHorizontal": "CENTER", + "textAlignVertical": "TOP", + "textAutoResize": "WIDTH_AND_HEIGHT", + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "visible": true, + "width": 450, + "x": 20, + "y": 20, + "styledTextSegments": [ + { + "characters": "[FR-03~06] 체험하기", + "end": 15, + "fillStyleId": "", + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "b": 1, "g": 1, "r": 1 }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "fontName": { "family": "Pretendard", "style": "Bold" }, + "fontSize": 48, + "fontWeight": 700, + "hyperlink": null, + "indentation": 0, + "letterSpacing": { "unit": "PERCENT", "value": 0 }, + "lineHeight": { "unit": "AUTO" }, + "listOptions": { "type": "NONE" }, + "start": 0, + "textCase": "ORIGINAL", + "textDecoration": "NONE", + "textStyleId": "" + } + ] + }, + "extra": {}, + "fieldErrors": {} + } + ], + "expectedTsx": "import { Flex, Text } from \"@devup-ui/react\";\n\nexport function Fr0306체험하기() {\n return (\n \n \n [FR-03~06] 체험하기\n \n \n );\n}\n" +} diff --git a/crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs b/crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs new file mode 100644 index 0000000..5a525a0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/manifest_trim_golden.rs @@ -0,0 +1,130 @@ +//! Golden test for the 6th-round collection trim. +//! +//! Both node sets in the fixture were collected read-only from the *same* +//! live Figma node in the same session: `legacy` with the pre-trim semantics +//! (133-field manifest, prototype-chain walk into `extra`, no default +//! omission) and `trimmed` with the shipped 77-field manifest plus default +//! omission. Shrinking the manifest is only safe if the DevupUI converter +//! still produces byte-identical TSX from the smaller snapshot, which is +//! exactly what this pins. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_node}; +use devup_mcp_figma::{RawNode, SnapshotChunk, merge_chunks}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Golden { + file_key: String, + root_id: String, + legacy_utf8_bytes: usize, + trimmed_utf8_bytes: usize, + legacy: Vec, + trimmed: Vec, + expected_tsx: String, +} + +fn golden() -> Golden { + serde_json::from_str(include_str!("fixtures/manifest-trim-golden.json")) + .expect("manifest trim golden fixture") +} + +fn tsx(golden: &Golden, nodes: Vec) -> String { + let snapshot = merge_chunks(vec![SnapshotChunk { + file_key: golden.file_key.clone(), + version: None, + root_ids: vec![golden.root_id.clone()], + nodes, + diagnostics: Vec::new(), + }]) + .expect("snapshot merges"); + generate_node(&snapshot, &golden.root_id, &CodegenOptions::default()) + .expect("codegen succeeds") + .tsx +} + +/// `generate_node` emits the bare JSX body; the server wraps it in the +/// component shell recorded as `expectedTsx`. Compare them whitespace-insensitively. +fn without_whitespace(value: &str) -> String { + value.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[test] +fn the_trimmed_manifest_produces_the_same_tsx_as_the_full_legacy_collection() { + let golden = golden(); + + let legacy_tsx = tsx(&golden, golden.legacy.clone()); + let trimmed_tsx = tsx(&golden, golden.trimmed.clone()); + + assert_eq!( + legacy_tsx, trimmed_tsx, + "trimming the collection manifest changed the converter's output" + ); + assert!( + without_whitespace(&golden.expected_tsx).contains(&without_whitespace(&trimmed_tsx)), + "generated JSX no longer matches the end-to-end TSX recorded from the live run:\n{trimmed_tsx}" + ); +} + +#[test] +fn the_recorded_end_to_end_tsx_carries_every_measured_design_value() { + // Values checked against the live Figma node: fill rgb(0, 0.36346, 0.40385) + // -> #005D67, 20px uniform padding, 9605px width, CENTER cross-axis + // alignment, white 48px Pretendard Bold text. + let expected = golden().expected_tsx; + for fragment in [ + "import { Flex, Text } from \"@devup-ui/react\";", + "alignItems=\"center\"", + "bg=\"#005D67\"", + "p=\"20px\"", + "w=\"9605px\"", + "color=\"#FFF\"", + "fontFamily=\"Pretendard\"", + "fontSize=\"48px\"", + "fontWeight=\"700\"", + "[FR-03~06] 체험하기", + ] { + assert!(expected.contains(fragment), "missing {fragment}"); + } +} + +#[test] +fn the_trimmed_collection_is_materially_smaller_for_the_same_node() { + let golden = golden(); + let node_count = golden.trimmed.len(); + assert_eq!(golden.legacy.len(), node_count); + + // Measured on the real node: 23,311 -> 3,862 bytes for two nodes, i.e. + // 11,655.5 -> 1,931 bytes per node. + let legacy_per_node = golden.legacy_utf8_bytes / node_count; + let trimmed_per_node = golden.trimmed_utf8_bytes / node_count; + assert!( + trimmed_per_node * 4 < legacy_per_node, + "expected at least a 4x reduction, got {legacy_per_node} -> {trimmed_per_node}" + ); +} + +#[test] +fn no_field_the_converter_reads_was_dropped_from_the_trimmed_nodes() { + let golden = golden(); + + // Every field the trimmed collection kept must still carry the same value + // it had under the full legacy collection - the trim may only ever remove + // fields, never change one. + for (legacy, trimmed) in golden.legacy.iter().zip(&golden.trimmed) { + assert_eq!(legacy.id, trimmed.id); + assert_eq!(legacy.node_type, trimmed.node_type); + for (field, value) in &trimmed.fields { + assert_eq!( + legacy.fields.get(field), + Some(value), + "field {field} on node {} changed under the trim", + trimmed.id + ); + } + assert!( + trimmed.extra.is_empty(), + "the trimmed collection must never populate `extra`" + ); + } +} diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 9ca35f2..da0523d 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -175,7 +175,11 @@ const nodes = pageNodes; nodes.push({ id: "__DEVUP_SNAPSHOT_CURSOR__", type: "DEVUP_INTERNAL", - fields: { nextOffset, complete, totalNodes: allNodes.length }, + // `offset` is what lets the Rust decoder tell a first page from a + // continuation page, which in turn decides whether the root must be + // present in this page. `nextOffset`/`complete`/`totalNodes` are the + // fields the shared `take_snapshot_cursor` reads. + fields: { offset, nextOffset, complete, totalNodes: allNodes.length }, extra: {}, fieldErrors: {}, }); diff --git a/crates/devup-mcp-figma/tests/envelope.rs b/crates/devup-mcp-figma/tests/envelope.rs index 4ba3e7c..800beb9 100644 --- a/crates/devup-mcp-figma/tests/envelope.rs +++ b/crates/devup-mcp-figma/tests/envelope.rs @@ -270,6 +270,21 @@ fn a_continuation_page_may_omit_the_root_that_a_prior_page_already_sent() { assert_eq!(decoded.snapshot.nodes.len(), 2); // real node + cursor marker } +#[test] +fn a_cursor_marker_missing_offset_is_rejected() { + // Regression: the script once emitted the marker without `offset`, so + // every real fast snapshot failed `peek_page_cursor` and silently fell + // back to legacy cursor collection. + let bad = mutate_envelope(|value| { + push_cursor_marker(value, 0, 2, true, 2); + value["snapshot"]["nodes"][2]["fields"] + .as_object_mut() + .unwrap() + .remove("offset"); + }); + assert_category(text_upstream_result(&bad), &target(), "cursorShape"); +} + #[test] fn duplicate_cursor_markers_are_rejected() { let bad = mutate_envelope(|value| { diff --git a/crates/devup-mcp-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index 21df545..6e74d68 100644 --- a/crates/devup-mcp-figma/tests/upstream_contract.rs +++ b/crates/devup-mcp-figma/tests/upstream_contract.rs @@ -373,7 +373,11 @@ fn fast_snapshot_is_paginated_manifest_scoped_and_read_only() { assert!(!code.contains("crc32")); assert!(code.contains("maxPayloadBytes")); assert!(code.contains("__DEVUP_SNAPSHOT_CURSOR__")); - assert!(code.contains("nextOffset")); + // Every field the Rust decoder reads off the cursor marker must actually + // be emitted. `offset` in particular is what distinguishes a first page + // from a continuation page in `envelope.rs::peek_page_cursor`; omitting + // it silently downgraded the whole fast path to legacy collection. + assert!(code.contains("fields: { offset, nextOffset, complete, totalNodes: allNodes.length }")); assert!(code.contains("MAX_ENVELOPE_BYTES")); assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); assert!(code.contains("devupFastSnapshotEnvelope")); From 3ff34e40d6d48dd13433b181effb978afe77d51f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 13:41:44 +0900 Subject: [PATCH 16/69] perf(figma): halve the fast envelope again and bound it by the text limit Second optimization pass over the fast node snapshot, measured on the same live node 3997:47467 (file 85CgSws3o5XsLv7aAwWJyS): 1,931 -> 1,411 bytes/node, i.e. 11,542 -> 1,411 (-87.8%) against the 6th-round brief's baseline, now under its 1,500 B/node target. The generated TSX is byte-identical before and after. Collection - Omit default-valued node fields: null, [], {}, empty *StyleId strings and a table of scalar defaults (rotation/cornerRadius/isAsset/isMask/ clipsContent/blendMode/strokeAlign/textCase/textDecoration/ text*Align/*AxisAlignItems/grid*). - Omit empty extra/fieldErrors/childrenIds - all three are #[serde(default)] or empty-iterator equivalent on the Rust side. - Drop a single styled text segment's keys that the TEXT node already carries; codegen/text.rs reads the node field first and only falls back to the segment, so only segment-exclusive keys (fontWeight, textStyleId, fillStyleId, start/end, listOptions, indentation, hyperlink) are kept. - Drop annotations and absoluteBoundingBox from the manifest: explore and Section indexing use their own projections and never read a manifest-collected snapshot. Which fields are safe to omit is proven, not assumed. The new devup-mcp-devup-ui/tests/default_omission_golden.rs replays the exact omission over the ten real WQUW-151 screens (1,500+ nodes, every node type in the file) and requires byte-identical TSX. Bisecting field-by-field first caught four rules that are NOT safe and are therefore excluded: - maxWidth/maxHeight: codegen/layout.rs compares `view.value("maxWidth") != Some(&Value::Null)`, so a present-null and an absent key take opposite branches. The previous commit's blanket null omission was a latent regression; this fixes it. - opacity: codegen/component.rs finds a hover variant via `number("opacity").is_some()` - presence itself is the signal. - visible: the component registration snapshot emits a "visible" line whenever the field is present. - layoutPositioning, per-corner radii and per-side stroke weights: read as a group / compared against a non-default, so dropping the members that happen to sit at their default changes the shorthand. Envelope bounding - A page carries the resources its nodes reference, so the node budget alone never bounded the envelope. Observed on node 3997:47749: 15,076 of the 15,360-byte text limit, 98.2%. The script now packs, builds, and if the whole envelope overshoots, halves the node budget and retries; fewer nodes can only reference fewer resources, so it converges. Simplification - One read_snapshot_cursor in snapshot.rs now parses the __DEVUP_SNAPSHOT_CURSOR__ marker for both the legacy collector and the fast decoder. They previously kept separate field lists, which is exactly how offset went missing. snapshot.js emits the same marker shape. - Merged serialize/serializeResource into one function with a resource flag (~45 duplicated lines). - Replaced utf8Encode, which built a whole byte array just to read its length, with the utf8ByteLength already in the file (~40 lines). - Deleted the dead 1MB MAX_ENVELOPE_BYTES check (the 15KB text check right after is strictly tighter) and the dead pagination mirror object (no Rust reader; the cursor marker is the single source of truth). Verified end to end by driving the freshly built release binary over stdio MCP against the real node: status complete, 1 Figma call, transport "text", fallbackUsed false, rawBytes 2822, quality complete/exact, fidelity 10000bp on every axis, 0 diagnostics, and TSX byte-identical to the recorded golden. Node 3997:47749 (39 nodes) paginates across 5 text rounds (9/8/9/5/8 nodes, 15076/14883/14643/9185/15299 bytes). --- .../tests/default_omission_golden.rs | 294 ++++++++++ crates/devup-mcp-figma/src/collector.rs | 62 +- crates/devup-mcp-figma/src/envelope.rs | 45 +- crates/devup-mcp-figma/src/lib.rs | 5 +- .../src/plugin_api_manifest.json | 2 +- .../src/scripts/fast_snapshot.js | 544 +++++++++--------- .../devup-mcp-figma/src/scripts/snapshot.js | 3 + crates/devup-mcp-figma/src/snapshot.rs | 70 +++ crates/devup-mcp-figma/tests/assets.rs | 2 +- crates/devup-mcp-figma/tests/collector.rs | 4 +- crates/devup-mcp-figma/tests/large_values.rs | 4 +- .../tests/upstream_contract.rs | 56 +- 12 files changed, 733 insertions(+), 358 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/default_omission_golden.rs diff --git a/crates/devup-mcp-devup-ui/tests/default_omission_golden.rs b/crates/devup-mcp-devup-ui/tests/default_omission_golden.rs new file mode 100644 index 0000000..8e9b810 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/default_omission_golden.rs @@ -0,0 +1,294 @@ +//! Pins the exact set of node fields `fast_snapshot.js` may omit from the +//! envelope, by replaying the omission over the ten real WQUW-151 screens +//! (1,500+ nodes covering every node type the file uses) and requiring the +//! generated TSX to stay byte-identical. +//! +//! The rules here and the `SCALAR_DEFAULTS` / `NULL_SENSITIVE_FIELDS` tables in +//! `crates/devup-mcp-figma/src/scripts/fast_snapshot.js` must stay in sync; +//! this test is what makes that safe to change. +//! +//! Fields deliberately NOT omitted, each for a reason visible in the converter: +//! - `maxWidth` / `maxHeight`: `codegen/layout.rs` compares +//! `view.value("maxWidth") != Some(&Value::Null)`, so a present-null and an +//! absent field take opposite branches. +//! - `opacity`: `codegen/component.rs` locates a hover variant with +//! `number("opacity").is_some()` - presence itself is the signal. +//! - `visible`: the component registration snapshot emits a `"visible"` line +//! whenever the field is present. +//! - `layoutPositioning`: compared against `Some("AUTO")`, so absence is not +//! equivalent to the default. +//! - per-corner radii and per-side stroke weights: they feed shorthand +//! builders that read the corners/sides as a group, so dropping the ones +//! that happen to be zero would change the shorthand. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{RawNode, Snapshot}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FrameFixture { + source: FrameSource, + snapshot: Snapshot, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FrameSource { + node_id: String, +} + +/// Mirrors `STYLE_ID_FIELDS` in `fast_snapshot.js`. +const STYLE_ID_FIELDS: &[&str] = &[ + "backgroundStyleId", + "effectStyleId", + "fillStyleId", + "gridStyleId", + "strokeStyleId", + "textStyleId", +]; + +/// Mirrors `NULL_SENSITIVE_FIELDS` in `fast_snapshot.js`: fields whose +/// present-null is load-bearing and must survive the omission. +const NULL_SENSITIVE_FIELDS: &[&str] = &["maxWidth", "maxHeight"]; + +/// Mirrors `SCALAR_DEFAULTS` in `fast_snapshot.js`. +fn scalar_defaults() -> Vec<(&'static str, Value)> { + use serde_json::json; + vec![ + ("rotation", json!(0)), + ("cornerRadius", json!(0)), + ("isAsset", json!(false)), + ("isMask", json!(false)), + ("clipsContent", json!(false)), + ("blendMode", json!("PASS_THROUGH")), + ("strokeAlign", json!("INSIDE")), + ("textCase", json!("ORIGINAL")), + ("textDecoration", json!("NONE")), + ("textAlignHorizontal", json!("LEFT")), + ("textAlignVertical", json!("TOP")), + ("counterAxisAlignItems", json!("MIN")), + ("primaryAxisAlignItems", json!("MIN")), + ("gridColumnCount", json!(0)), + ("gridRowCount", json!(0)), + ("gridColumnGap", json!(0)), + ("gridRowGap", json!(0)), + ("gridColumnAnchorIndex", json!(-1)), + ("gridRowAnchorIndex", json!(-1)), + ] +} + +fn numbers_equal(left: &Value, right: &Value) -> bool { + match (left.as_f64(), right.as_f64()) { + (Some(left), Some(right)) => (left - right).abs() < f64::EPSILON, + _ => left == right, + } +} + +fn is_omittable(field: &str, value: &Value) -> bool { + if value.is_null() { + return !NULL_SENSITIVE_FIELDS.contains(&field); + } + if value.as_array().is_some_and(Vec::is_empty) { + return true; + } + if value.as_object().is_some_and(serde_json::Map::is_empty) { + return true; + } + if value.as_str() == Some("") && STYLE_ID_FIELDS.contains(&field) { + return true; + } + scalar_defaults() + .iter() + .any(|(name, default)| *name == field && numbers_equal(value, default)) +} + +fn omit_defaults(node: &mut RawNode) -> usize { + let before = node.fields.len(); + node.fields + .retain(|field, value| !is_omittable(field, value)); + node.extra.clear(); + before - node.fields.len() +} + +fn fixtures() -> Vec { + [ + include_str!("fixtures/wquw-151-frames/3879-35503.json"), + include_str!("fixtures/wquw-151-frames/3879-35518.json"), + include_str!("fixtures/wquw-151-frames/3879-35569.json"), + include_str!("fixtures/wquw-151-frames/3879-35652.json"), + include_str!("fixtures/wquw-151-frames/3879-35729.json"), + include_str!("fixtures/wquw-151-frames/3879-35887.json"), + include_str!("fixtures/wquw-151-frames/3879-35973.json"), + include_str!("fixtures/wquw-151-frames/3879-36059.json"), + include_str!("fixtures/wquw-151-frames/3879-36108.json"), + include_str!("fixtures/wquw-151-frames/3879-36144.json"), + ] + .into_iter() + .map(|raw| serde_json::from_str(raw).expect("WQUW-151 frame fixture")) + .collect() +} + +fn tsx(snapshot: &Snapshot, root_id: &str) -> String { + generate_component( + snapshot, + root_id, + &CodegenOptions { + component_name: Some("OmissionProbe".to_owned()), + include_diagnostics: true, + inline_instances: true, + ..CodegenOptions::default() + }, + ) + .unwrap_or_else(|error| panic!("{root_id} codegen failed: {error}")) + .tsx +} + +#[test] +fn omitting_default_valued_fields_keeps_every_real_screen_byte_identical() { + let mut checked_nodes = 0_usize; + let mut dropped_fields = 0_usize; + + for fixture in fixtures() { + let root_id = fixture.source.node_id.clone(); + let before = tsx(&fixture.snapshot, &root_id); + + let mut trimmed = fixture.snapshot.clone(); + for node in trimmed.nodes.values_mut() { + dropped_fields += omit_defaults(node); + } + checked_nodes += trimmed.nodes.len(); + + assert_eq!( + before, + tsx(&trimmed, &root_id), + "omitting default-valued fields changed the TSX for screen {root_id}" + ); + } + + // Guards against a fixture set that silently shrank to nothing. + assert!( + checked_nodes > 1_000, + "expected the ten real screens to cover >1000 nodes, saw {checked_nodes}" + ); + assert!( + dropped_fields > 10_000, + "expected the omission to drop >10000 fields, saw {dropped_fields}" + ); +} + +/// Mirrors `SEGMENT_ONLY_KEYS` in `fast_snapshot.js`. +const SEGMENT_ONLY_KEYS: &[&str] = &[ + "start", + "end", + "characters", + "fontWeight", + "textStyleId", + "fillStyleId", + "listOptions", + "indentation", + "hyperlink", +]; + +#[test] +fn deduping_single_segment_text_keeps_every_real_screen_byte_identical() { + // A lone styled text segment restates typography the TEXT node already + // carries, and `codegen/text.rs` reads the node field first, falling back + // to the segment only when the node lacks it. + let mut single_segment_nodes = 0_usize; + + for fixture in fixtures() { + let root_id = fixture.source.node_id.clone(); + let before = tsx(&fixture.snapshot, &root_id); + + let mut trimmed = fixture.snapshot.clone(); + for node in trimmed.nodes.values_mut() { + let Some(segments) = node + .fields + .get_mut("styledTextSegments") + .and_then(Value::as_array_mut) + else { + continue; + }; + if segments.len() != 1 { + continue; + } + single_segment_nodes += 1; + if let Some(only) = segments[0].as_object_mut() { + only.retain(|key, _| SEGMENT_ONLY_KEYS.contains(&key.as_str())); + } + } + + assert_eq!( + before, + tsx(&trimmed, &root_id), + "deduping the lone text segment changed the TSX for screen {root_id}" + ); + } + + assert!( + single_segment_nodes > 200, + "expected the fixtures to cover >200 single-segment text nodes, saw {single_segment_nodes}" + ); +} + +#[test] +fn presence_sensitive_fields_are_never_omitted() { + // Each of these takes a different branch when absent than when present at + // its default, so the script must keep them verbatim. + for field in NULL_SENSITIVE_FIELDS { + assert!(!is_omittable(field, &Value::Null), "{field} must survive"); + } + for (field, value) in [ + ("opacity", serde_json::json!(1)), + ("visible", serde_json::json!(true)), + ("layoutPositioning", serde_json::json!("AUTO")), + ("topLeftRadius", serde_json::json!(0)), + ("topRightRadius", serde_json::json!(0)), + ("bottomLeftRadius", serde_json::json!(0)), + ("bottomRightRadius", serde_json::json!(0)), + ("strokeWeight", serde_json::json!(1)), + ("strokeTopWeight", serde_json::json!(1)), + ("strokeRightWeight", serde_json::json!(1)), + ("strokeBottomWeight", serde_json::json!(1)), + ("strokeLeftWeight", serde_json::json!(1)), + ] { + assert!(!is_omittable(field, &value), "{field} must survive"); + } +} + +#[test] +fn every_null_field_the_fixtures_contain_is_classified_deliberately() { + // A future manifest addition that shows up as null must be judged, not + // silently swept into the blanket null rule. + let mut null_fields = std::collections::BTreeSet::new(); + for fixture in fixtures() { + for node in fixture.snapshot.nodes.values() { + for (field, value) in &node.fields { + if value.is_null() { + null_fields.insert(field.clone()); + } + } + } + } + let known = [ + "componentPropertyReferences", + "inferredAutoLayout", + "maxHeight", + "maxWidth", + "minHeight", + "minWidth", + "targetAspectRatio", + "variantProperties", + ]; + let unexpected = null_fields + .iter() + .filter(|field| !known.contains(&field.as_str())) + .cloned() + .collect::>(); + assert!( + unexpected.is_empty(), + "unclassified null-valued fields appeared: {unexpected:?}" + ); +} diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index fc2a4bb..3e0beb7 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -17,12 +17,12 @@ use crate::{ AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, BatchLimits, BuiltinScript, DevupError, ErrorCode, ExploreReadOptions, FigmaTarget, LargeValueAssembler, LargeValueReadOptions, RawNode, ReadToolCall, ResourceBatch, ResourceScope, ResourceStyleRef, - SearchReadOptions, SectionIndex, SnapshotChunk, SnapshotReadOptions, UnresolvedResource, - UpstreamResult, UsedResourceRefs, asset_export_from_result, build_section_index, - collect_used_resource_refs, decode_fast_multi_snapshot, decode_fast_snapshot, - decode_fast_theme, merge_chunks, + SNAPSHOT_CURSOR_ID, SearchReadOptions, SectionIndex, SnapshotChunk, SnapshotCursor, + SnapshotReadOptions, UnresolvedResource, UpstreamResult, UsedResourceRefs, + asset_export_from_result, build_section_index, collect_used_resource_refs, + decode_fast_multi_snapshot, decode_fast_snapshot, decode_fast_theme, merge_chunks, metadata::{MetadataResult, metadata_from_result_for_target}, - plan_batches, resolve_asset_selections, snapshot_chunk_from_result, + plan_batches, read_snapshot_cursor, resolve_asset_selections, snapshot_chunk_from_result, variables::{ VariableBatchResult, VariableCatalog, batch_from_result, catalog_from_result, merge_used_resource_results, merge_variable_results, @@ -38,7 +38,7 @@ const USED_RESOURCE_BATCH_BYTES: usize = 12_000; // Consumer relations can be huge. Compact, bounded fragments are expanded // back to the exhaustive shape in Rust without dropping any relation. const STYLE_CONSUMER_BATCH_SIZE: usize = 320; -pub(crate) const SNAPSHOT_CURSOR_ID: &str = "__DEVUP_SNAPSHOT_CURSOR__"; + const MAX_REFERENCE_PNG_BYTES: usize = 16 * 1024 * 1024; const MAX_REFERENCE_PNG_BASE64_BYTES: usize = MAX_REFERENCE_PNG_BYTES.div_ceil(3) * 4; const MAX_REFERENCE_PNG_DIMENSION: u32 = 8_192; @@ -792,6 +792,7 @@ impl CollectorSession { // and enqueues any large-value follow-ups they declared. let total_nodes = chunk.nodes.len(); let cursor = take_snapshot_cursor(&mut chunk)?.unwrap_or(SnapshotCursor { + offset: 0, next_offset: total_nodes, complete: true, total_nodes, @@ -1980,53 +1981,14 @@ fn reference_png_decode_error(error: ImageError) -> DevupError { } } -#[derive(Debug, Clone, Copy)] -struct SnapshotCursor { - next_offset: usize, - complete: bool, - total_nodes: usize, -} - fn take_snapshot_cursor(chunk: &mut SnapshotChunk) -> Result, DevupError> { - let positions = chunk - .nodes - .iter() - .enumerate() - .filter_map(|(index, node)| (node.id == SNAPSHOT_CURSOR_ID).then_some(index)) - .collect::>(); - let Some(&position) = positions.first() else { + let Some(cursor) = read_snapshot_cursor(&chunk.nodes) + .map_err(|message| invalid_call(message.korean_message()))? + else { return Ok(None); }; - if positions.len() != 1 { - return Err(invalid_call( - "Figma snapshot 응답에 cursor가 중복되었습니다.", - )); - } - let cursor = chunk.nodes.remove(position); - if cursor.node_type != "DEVUP_INTERNAL" { - return Err(invalid_call( - "Figma snapshot cursor 형식이 올바르지 않습니다.", - )); - } - let cursor = cursor.typed_view(); - let next_offset = cursor - .value("nextOffset") - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(|| invalid_call("Figma snapshot cursor의 nextOffset이 없습니다."))?; - let complete = cursor - .bool("complete") - .ok_or_else(|| invalid_call("Figma snapshot cursor의 complete가 없습니다."))?; - let total_nodes = cursor - .value("totalNodes") - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(|| invalid_call("Figma snapshot cursor의 totalNodes가 없습니다."))?; - Ok(Some(SnapshotCursor { - next_offset, - complete, - total_nodes, - })) + chunk.nodes.retain(|node| node.id != SNAPSHOT_CURSOR_ID); + Ok(Some(cursor)) } fn invalid_call(message: &str) -> DevupError { diff --git a/crates/devup-mcp-figma/src/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index 1f81582..c264e98 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, de::DeserializeOwned}; use serde_json::{Value, json}; use crate::{ - DevupError, ErrorCode, FigmaTarget, RawNode, ResourceKind, SnapshotChunk, UpstreamResult, - collect_used_resource_refs, collector::SNAPSHOT_CURSOR_ID, + DevupError, ErrorCode, FigmaTarget, ResourceKind, SnapshotChunk, UpstreamResult, + collect_used_resource_refs, read_snapshot_cursor, }; const MAX_TEXT_ENVELOPE_BYTES: usize = 15 * 1024; @@ -131,7 +131,7 @@ fn decode_fast_snapshot_for_roots( else { return Err(invalid("textEnvelopeMissing")); }; - let page = peek_page_cursor(&envelope.snapshot.nodes)?; + let page = peek_page_cursor(&envelope.snapshot)?; validate_envelope(&envelope, target, expected_root_ids, utf8_bytes, page)?; Ok(FastSnapshotPayload { snapshot: envelope.snapshot, @@ -173,45 +173,28 @@ pub fn decode_fast_theme( } /// Whether an envelope's node list is a partial page of a larger, paginated -/// fetch, derived from the `__DEVUP_SNAPSHOT_CURSOR__` marker node every fast -/// snapshot script appends (`offset`, `nextOffset`, `complete`, `totalNodes`). +/// fetch. Derived from the shared `__DEVUP_SNAPSHOT_CURSOR__` reader so the +/// marker is only ever parsed against one field list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PageCursor { is_first_page: bool, is_final_page: bool, } -fn peek_page_cursor(nodes: &[RawNode]) -> Result { - let marker = nodes - .iter() - .filter(|node| node.id == SNAPSHOT_CURSOR_ID) - .collect::>(); - match marker.as_slice() { +fn peek_page_cursor(chunk: &SnapshotChunk) -> Result { + match read_snapshot_cursor(&chunk.nodes).map_err(|error| invalid(error.category()))? { + Some(cursor) => Ok(PageCursor { + is_first_page: cursor.offset == 0, + is_final_page: cursor.complete, + }), // No cursor marker at all: treat as a single, complete, self-contained // envelope (the shape every fast snapshot had before pagination). - // Real script output always includes the marker; this only matters - // for hand-built payloads (tests, older fixtures). - [] => Ok(PageCursor { + // Real script output always includes the marker; this only matters for + // hand-built payloads (tests, older fixtures). + None => Ok(PageCursor { is_first_page: true, is_final_page: true, }), - [marker] => { - if marker.node_type != "DEVUP_INTERNAL" { - return Err(invalid("cursorShape")); - } - let view = marker.typed_view(); - let offset = view - .number("offset") - .ok_or_else(|| invalid("cursorShape"))?; - let complete = view - .bool("complete") - .ok_or_else(|| invalid("cursorShape"))?; - Ok(PageCursor { - is_first_page: offset == 0.0, - is_final_page: complete, - }) - } - _ => Err(invalid("cursorMultiplicity")), } } diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 6917148..1c38309 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -59,8 +59,9 @@ pub use section::{ }; pub use snapshot::{ ChildCountMismatch, CompletenessState, Diagnostic, DiagnosticSeverity, FidelityImpact, - FieldLocation, MissingChild, ParentMismatch, RawNode, Snapshot, SnapshotAudit, SnapshotChunk, - TypedNode, merge_chunks, snapshot_chunk_from_result, + FieldLocation, MissingChild, ParentMismatch, RawNode, SNAPSHOT_CURSOR_ID, Snapshot, + SnapshotAudit, SnapshotChunk, SnapshotCursor, SnapshotCursorError, TypedNode, merge_chunks, + read_snapshot_cursor, snapshot_chunk_from_result, }; pub use source::{ SelectedSource, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, diff --git a/crates/devup-mcp-figma/src/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index 2b4dc1f..b1c8d8b 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -1,5 +1,5 @@ [ - "absoluteBoundingBox", "annotations", "arcData", "backgroundStyleId", "blendMode", + "arcData", "backgroundStyleId", "blendMode", "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentProperties", "componentPropertyDefinitions", "componentPropertyReferences", "constraints", "cornerRadius", "counterAxisAlignItems", "dashPattern", "effects", "effectStyleId", "fills", diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index da0523d..5d97add 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -21,21 +21,19 @@ const maxPayloadBytes = Math.min( Math.max(4096, Math.floor(Number(pageOptions.maxPayloadBytes) || 12000)), ); const MAX_TEXT_ENVELOPE_BYTES = 15 * 1024; -const MAX_ENVELOPE_BYTES = 1024 * 1024; -// Values that carry no information beyond "this field is at its default" are -// dropped from the envelope. Consumers must treat an absent key exactly like -// its default (this already holds for every accessor in the Rust codegen, -// which reads through Option-returning TypedNode helpers). -// -// `""` is only dropped for *StyleId fields: Figma reports an unbound style -// as `""`, and both consumers of these fields already treat `""` and -// "field absent" identically — -// - `resources.rs::is_resource_id` rejects empty IDs before treating a -// *StyleId field as a real style reference (used by both the fast-path -// JS resource scanner above and the legacy Rust scanner), and -// - `codegen/text.rs` looks `textStyleId` up in a token map, where an -// empty-string key can never match (same `None` result as a missing key). +// A field whose value equals its default carries no information the converter +// can't recover from the key being absent, so it is dropped from the envelope. +// Which fields qualify is NOT a judgement call: it is proven for every rule +// below by `devup-mcp-devup-ui/tests/default_omission_golden.rs`, which +// replays this exact omission over ten real screens (1,500+ nodes) and +// requires the generated TSX to stay byte-identical. Keep the two tables in +// sync with that test. + +// Figma reports an unbound style as `""`, and both readers of these fields +// already treat `""` and "absent" the same: `resources.rs::is_resource_id` +// rejects empty IDs, and `codegen/text.rs` looks the ID up in a token map +// where an empty key can never match. const STYLE_ID_FIELDS = new Set([ "backgroundStyleId", "effectStyleId", @@ -44,26 +42,67 @@ const STYLE_ID_FIELDS = new Set([ "strokeStyleId", "textStyleId", ]); + +// `codegen/layout.rs` compares `view.value("maxWidth") != Some(&Value::Null)`, +// so for these two a present-null and an absent key take opposite branches. +// Their null must survive. +const NULL_SENSITIVE_FIELDS = new Set(["maxWidth", "maxHeight"]); + +// Deliberately absent from this table, each because the converter branches on +// the field's *presence* rather than its value: `opacity` (hover-variant +// detection), `visible` (component registration snapshot), `layoutPositioning` +// (compared against "AUTO"), and the per-corner radii / per-side stroke +// weights (read as a group by the shorthand builders). +const SCALAR_DEFAULTS = new Map([ + ["rotation", 0], + ["cornerRadius", 0], + ["isAsset", false], + ["isMask", false], + ["clipsContent", false], + ["blendMode", "PASS_THROUGH"], + ["strokeAlign", "INSIDE"], + ["textCase", "ORIGINAL"], + ["textDecoration", "NONE"], + ["textAlignHorizontal", "LEFT"], + ["textAlignVertical", "TOP"], + ["counterAxisAlignItems", "MIN"], + ["primaryAxisAlignItems", "MIN"], + ["gridColumnCount", 0], + ["gridRowCount", 0], + ["gridColumnGap", 0], + ["gridRowGap", 0], + ["gridColumnAnchorIndex", -1], + ["gridRowAnchorIndex", -1], +]); + +// Keys a styled text segment carries that the TEXT node itself does not, so +// they must survive even when the node has a single segment. +const SEGMENT_ONLY_KEYS = new Set([ + "start", + "end", + "characters", + "fontWeight", + "textStyleId", + "fillStyleId", + "listOptions", + "indentation", + "hyperlink", +]); + function isOmittableDefault(value, name) { - if (value === null) return true; - if (Array.isArray(value) && value.length === 0) return true; + if (value === null) return !NULL_SENSITIVE_FIELDS.has(name); + if (Array.isArray(value)) return value.length === 0; + if (typeof value === "object") return Object.keys(value).length === 0; if (value === "" && STYLE_ID_FIELDS.has(name)) return true; - return false; + return SCALAR_DEFAULTS.has(name) && SCALAR_DEFAULTS.get(name) === value; } -function propertyNames(value) { - // Only ever look at the checked-in manifest. No prototype-chain walk, no - // "extra" bucket: an unlisted Figma Plugin API property is never collected. - const names = []; - for (const name of manifest) { - try { - if (name in value) names.push(name); - } catch (_) {} - } - return names; -} - -function serialize(value, seen = new WeakSet(), depth = 0) { +// One serializer for both node fields and variable/style resources. Resources +// need the prototype chain walked (their data lives on accessors, not own +// keys) and a few structural keys skipped; node fields never do, because the +// manifest already names every property worth reading. +const RESOURCE_SKIPPED_KEYS = new Set(["parent", "children", "consumers"]); +function serialize(value, resource = false, seen = new WeakSet(), depth = 0) { if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; if (typeof value === "undefined") return { $undefined: true }; if (typeof value === "bigint") return { $bigint: value.toString() }; @@ -77,20 +116,36 @@ function serialize(value, seen = new WeakSet(), depth = 0) { ) { return { $nodeId: value.id, $nodeType: value.type }; } - if (Array.isArray(value)) return value.map((item) => serialize(item, seen, depth + 1)); + if (Array.isArray(value)) return value.map((item) => serialize(item, resource, seen, depth + 1)); if (ArrayBuffer.isView(value)) { return { $binary: value.constructor.name, byteLength: value.byteLength }; } if (value instanceof ArrayBuffer) return { $binary: "ArrayBuffer", byteLength: value.byteLength }; if (seen.has(value)) return { $circular: true }; seen.add(value); + + let keys; + if (resource) { + const names = new Set(Object.keys(value)); + let current = value; + while (current && current !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(current)) names.add(name); + current = Object.getPrototypeOf(current); + } + keys = [...names].sort().filter((name) => !name.startsWith("_") && !RESOURCE_SKIPPED_KEYS.has(name)); + } else { + keys = Object.keys(value).sort(); + } + const result = {}; - for (const key of Object.keys(value).sort()) { + for (const key of keys) { try { - const serialized = serialize(value[key], seen, depth + 1); + const serialized = serialize(value[key], resource, seen, depth + 1); if (!(serialized && serialized.$unsupported === "function")) result[key] = serialized; } catch (error) { - result[key] = { $error: String(error && error.message ? error.message : error) }; + result[key] = resource + ? { $error: "unavailable" } + : { $error: String(error && error.message ? error.message : error) }; } } seen.delete(value); @@ -100,12 +155,17 @@ function serialize(value, seen = new WeakSet(), depth = 0) { function snapshotNode(node) { const fields = {}; const fieldErrors = {}; - fields.parentId = node.parent ? node.parent.id : null; - fields.childrenIds = "children" in node ? node.children.map((child) => child.id) : []; + if (node.parent) fields.parentId = node.parent.id; + const childrenIds = "children" in node ? node.children.map((child) => child.id) : []; + if (childrenIds.length > 0) fields.childrenIds = childrenIds; - for (const name of propertyNames(node)) { + // Only ever look at the checked-in manifest. No prototype-chain walk, no + // "extra" bucket: an unlisted Figma Plugin API property is never collected. + for (const name of manifest) { + let value; try { - const value = node[name]; + if (!(name in node)) continue; + value = node[name]; if (typeof value === "function") continue; const serialized = serialize(value); if (!isOmittableDefault(serialized, name)) fields[name] = serialized; @@ -116,12 +176,27 @@ function snapshotNode(node) { if (node.type === "TEXT" && typeof node.getStyledTextSegments === "function") { try { const segments = serialize(node.getStyledTextSegments(textSegmentManifest)); - if (!isOmittableDefault(segments)) fields.styledTextSegments = segments; + // A single segment restates typography the node already carries at the + // top level, and `codegen/text.rs` reads the node field first and only + // falls back to the segment. Keep just the keys that exist nowhere else. + // Proven over 269 real single-segment text nodes by + // `devup-mcp-devup-ui/tests/default_omission_golden.rs`. + if (segments.length === 1) { + const only = segments[0]; + for (const key of Object.keys(only)) { + if (!SEGMENT_ONLY_KEYS.has(key)) delete only[key]; + } + } + if (segments.length > 0) fields.styledTextSegments = segments; } catch (error) { fieldErrors.styledTextSegments = String(error && error.message ? error.message : error); } } - return { id: node.id, type: node.type, fields, extra: {}, fieldErrors }; + // `extra` and `fieldErrors` are `#[serde(default)]` on the Rust `RawNode`, + // so an empty one is the same as an absent one on the wire. + const snapshotted = { id: node.id, type: node.type, fields }; + if (Object.keys(fieldErrors).length > 0) snapshotted.fieldErrors = fieldErrors; + return snapshotted; } const allNodes = []; @@ -156,34 +231,6 @@ function jsonByteLength(value) { return utf8ByteLength(JSON.stringify(value)); } -// Pack as many nodes as fit under maxPayloadBytes starting at offset. This is -// the same dynamic, byte-budget-driven pagination the legacy cursor snapshot -// already uses, applied to the fast (single-call, resource-inclusive) path. -const pageNodeBudget = maxPayloadBytes - 1024; -const pageNodes = []; -let pagePayloadBytes = 2; -for (let index = offset; index < allNodes.length; index += 1) { - const snapshotted = snapshotNode(allNodes[index]); - const nodeBytes = jsonByteLength(snapshotted) + (pageNodes.length ? 1 : 0); - if (pageNodes.length && pagePayloadBytes + nodeBytes > pageNodeBudget) break; - pageNodes.push(snapshotted); - pagePayloadBytes += nodeBytes; -} -const nextOffset = Math.min(allNodes.length, offset + pageNodes.length); -const complete = nextOffset >= allNodes.length; -const nodes = pageNodes; -nodes.push({ - id: "__DEVUP_SNAPSHOT_CURSOR__", - type: "DEVUP_INTERNAL", - // `offset` is what lets the Rust decoder tell a first page from a - // continuation page, which in turn decides whether the root must be - // present in this page. `nextOffset`/`complete`/`totalNodes` are the - // fields the shared `take_snapshot_cursor` reads. - fields: { offset, nextOffset, complete, totalNodes: allNodes.length }, - extra: {}, - fieldErrors: {}, -}); - function styleTypeForField(field) { if (field === "textStyleId") return "TEXT"; if (["fillStyleId", "strokeStyleId", "backgroundStyleId"].includes(field)) return "PAINT"; @@ -192,11 +239,9 @@ function styleTypeForField(field) { return null; } -const variableIds = new Set(); -const styleTypes = new Map(); -function scanResources(value, fieldName = "") { +function scanResources(value, variableIds, styleTypes) { if (Array.isArray(value)) { - for (const child of value) scanResources(child, fieldName); + for (const child of value) scanResources(child, variableIds, styleTypes); return; } if (!value || typeof value !== "object") return; @@ -220,178 +265,84 @@ function scanResources(value, fieldName = "") { ) { if (!styleTypes.has(child)) styleTypes.set(child, styleType); } - scanResources(child, field || fieldName); + scanResources(child, variableIds, styleTypes); } } -// Only the nodes actually shipped in THIS page are scanned, so the resources -// this page returns stay self-consistent with this page's own integrity -// counters. The host (devup-mcp) merges resources across pages. -scanResources(nodes); -function resourcePropertyNames(value) { - const names = new Set(Object.keys(value)); - let current = value; - while (current && current !== Object.prototype) { - for (const name of Object.getOwnPropertyNames(current)) names.add(name); - current = Object.getPrototypeOf(current); - } - return [...names].sort(); -} +// Resolves every variable/style the given page of nodes references. Only the +// nodes shipped in THIS page are scanned, so a page's resource block stays +// consistent with its own integrity counters; devup-mcp merges across pages. +async function collectResources(nodes) { + const variableIds = new Set(); + const styleTypes = new Map(); + scanResources(nodes, variableIds, styleTypes); -function serializeResource(value, seen = new WeakSet(), depth = 0) { - if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; - if (typeof value === "undefined") return { $undefined: true }; - if (typeof value === "bigint") return { $bigint: value.toString() }; - if (["function", "symbol"].includes(typeof value)) return { $unsupported: typeof value }; - if (depth > 12) return { $truncated: "max-depth" }; - if ( - typeof value === "object" && - "parent" in value && - typeof value.id === "string" && - typeof value.type === "string" - ) { - return { $nodeId: value.id, $nodeType: value.type }; - } - if (Array.isArray(value)) { - return value.map((item) => serializeResource(item, seen, depth + 1)); - } - if (ArrayBuffer.isView(value)) { - return { $binary: value.constructor.name, byteLength: value.byteLength }; - } - if (value instanceof ArrayBuffer) return { $binary: "ArrayBuffer", byteLength: value.byteLength }; - if (seen.has(value)) return { $circular: true }; - seen.add(value); - const result = {}; - for (const name of resourcePropertyNames(value)) { - if (name.startsWith("_") || ["parent", "children", "consumers"].includes(name)) continue; - try { - const serialized = serializeResource(value[name], seen, depth + 1); - if (!(serialized && serialized.$unsupported === "function")) result[name] = serialized; - } catch (_) { - result[name] = { $error: "unavailable" }; - } - } - seen.delete(value); - return result; -} + const sortedVariableIds = [...variableIds].sort(); + const sortedStyles = [...styleTypes.entries()] + .map(([id, styleType]) => ({ id, styleType })) + .sort((left, right) => left.id.localeCompare(right.id)); -const sortedVariableIds = [...variableIds].sort(); -const sortedStyles = [...styleTypes.entries()] - .map(([id, styleType]) => ({ id, styleType })) - .sort((left, right) => left.id.localeCompare(right.id)); -const variableJobs = sortedVariableIds.map(async (id) => { - try { - const variable = await figma.variables.getVariableByIdAsync(id); - return variable - ? { - kind: "variable", - value: serializeResource(variable), - collectionId: variable.variableCollectionId, + const results = await Promise.all([ + ...sortedVariableIds.map(async (id) => { + try { + const variable = await figma.variables.getVariableByIdAsync(id); + return variable + ? { + kind: "variable", + value: serialize(variable, true), + collectionId: variable.variableCollectionId, + } + : { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; + } catch (_) { + return { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; + } + }), + ...sortedStyles.map(async ({ id, styleType }) => { + try { + const style = await figma.getStyleByIdAsync(id); + if (!style) { + return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; } - : { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; - } catch (_) { - return { kind: "unresolved", value: { id, kind: "variable", reason: "notFoundOrUnavailable" } }; - } -}); -const styleJobs = sortedStyles.map(async ({ id, styleType }) => { - try { - const style = await figma.getStyleByIdAsync(id); - if (!style) { - return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; - } - return { - kind: "style", - value: { - ...serializeResource(style), - styleType, - value: serializeResource( - styleType === "PAINT" - ? style.paints - : styleType === "EFFECT" - ? style.effects - : styleType === "GRID" - ? style.layoutGrids - : style, - ), - }, - }; - } catch (_) { - return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; - } -}); -const resourceResults = await Promise.all([...variableJobs, ...styleJobs]); -const collectionIds = [...new Set(resourceResults - .filter((result) => result.kind === "variable" && result.collectionId) - .map((result) => result.collectionId))].sort(); -const collectionJobs = collectionIds.map(async (id) => { - try { - const collection = await figma.variables.getVariableCollectionByIdAsync(id); - return collection ? serializeResource(collection) : null; - } catch (_) { - return null; - } -}); -const collections = (await Promise.all(collectionJobs)).filter((collection) => collection !== null); -const variables = resourceResults - .filter((result) => result.kind === "variable") - .map((result) => result.value); -const styles = resourceResults - .filter((result) => result.kind === "style") - .map((result) => result.value); -const unresolved = resourceResults - .filter((result) => result.kind === "unresolved") - .map((result) => result.value); - -function utf8Encode(value) { - const bytes = []; - for (let index = 0; index < value.length; index += 1) { - let codePoint = value.charCodeAt(index); - if (codePoint >= 0xd800 && codePoint <= 0xdbff) { - const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0; - if (next >= 0xdc00 && next <= 0xdfff) { - codePoint = 0x10000 + ((codePoint - 0xd800) << 10) + (next - 0xdc00); - index += 1; - } else { - codePoint = 0xfffd; + return { + kind: "style", + value: { + ...serialize(style, true), + styleType, + value: serialize( + styleType === "PAINT" + ? style.paints + : styleType === "EFFECT" + ? style.effects + : styleType === "GRID" + ? style.layoutGrids + : style, + true, + ), + }, + }; + } catch (_) { + return { kind: "unresolved", value: { id, kind: "style", reason: "notFoundOrUnavailable" } }; } - } else if (codePoint >= 0xdc00 && codePoint <= 0xdfff) { - codePoint = 0xfffd; - } + }), + ]); - if (codePoint < 0x80) { - bytes.push(codePoint); - } else if (codePoint < 0x800) { - bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)); - } else if (codePoint < 0x10000) { - bytes.push( - 0xe0 | (codePoint >> 12), - 0x80 | ((codePoint >> 6) & 0x3f), - 0x80 | (codePoint & 0x3f), - ); - } else { - bytes.push( - 0xf0 | (codePoint >> 18), - 0x80 | ((codePoint >> 12) & 0x3f), - 0x80 | ((codePoint >> 6) & 0x3f), - 0x80 | (codePoint & 0x3f), - ); + const collectionIds = [...new Set(results + .filter((result) => result.kind === "variable" && result.collectionId) + .map((result) => result.collectionId))].sort(); + const collections = (await Promise.all(collectionIds.map(async (id) => { + try { + const collection = await figma.variables.getVariableCollectionByIdAsync(id); + return collection ? serialize(collection, true) : null; + } catch (_) { + return null; } - } - return new Uint8Array(bytes); -} + }))).filter((collection) => collection !== null); -const envelope = { - kind: "devupFastSnapshotEnvelope", - schemaVersion: 1, - source: { fileKey: figma.fileKey || "", rootId: envelopeRootId }, - snapshot: { - fileKey: figma.fileKey || "", - version: null, - rootIds: roots.map((root) => root.id), - nodes, - diagnostics: [], - }, - resources: { + const variables = results.filter((result) => result.kind === "variable").map((result) => result.value); + const styles = results.filter((result) => result.kind === "style").map((result) => result.value); + const unresolved = results.filter((result) => result.kind === "unresolved").map((result) => result.value); + + return { collections, variables, styles, @@ -401,34 +352,105 @@ const envelope = { localComplete: false, usedRemoteComplete: unresolved.length === 0, unresolved, - }, - pagination: { offset, nextOffset, complete, totalNodes: allNodes.length }, - integrity: { - nodeCount: nodes.length, - variableRefCount: sortedVariableIds.length, - styleRefCount: sortedStyles.length, - utf8Bytes: 0, - }, -}; - -let envelopeBytes = new Uint8Array(); -for (let attempt = 0; attempt < 8; attempt += 1) { - envelopeBytes = utf8Encode(JSON.stringify(envelope)); - if (envelope.integrity.utf8Bytes === envelopeBytes.length) break; - envelope.integrity.utf8Bytes = envelopeBytes.length; + $variableRefCount: sortedVariableIds.length, + $styleRefCount: sortedStyles.length, + }; } -envelopeBytes = utf8Encode(JSON.stringify(envelope)); -if (envelope.integrity.utf8Bytes !== envelopeBytes.length) { - throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); + +// Packs as many nodes as fit under `budget`, starting at `offset`. Same +// dynamic, byte-budget-driven pagination the legacy cursor snapshot uses. +function packPage(budget) { + const pageNodes = []; + let payloadBytes = 2; + for (let index = offset; index < allNodes.length; index += 1) { + const snapshotted = snapshotNode(allNodes[index]); + const nodeBytes = jsonByteLength(snapshotted) + (pageNodes.length ? 1 : 0); + if (pageNodes.length && payloadBytes + nodeBytes > budget) break; + pageNodes.push(snapshotted); + payloadBytes += nodeBytes; + } + return pageNodes; } -if (envelopeBytes.length > MAX_ENVELOPE_BYTES) { - throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); + +function buildEnvelope(pageNodes, resources) { + const nextOffset = Math.min(allNodes.length, offset + pageNodes.length); + const { $variableRefCount, $styleRefCount, ...resourceBlock } = resources; + const nodes = [ + ...pageNodes, + { + id: "__DEVUP_SNAPSHOT_CURSOR__", + type: "DEVUP_INTERNAL", + // `offset` is what lets the Rust decoder tell a first page from a + // continuation page, which decides whether the root must be present + // here. All four fields are read by the shared `read_snapshot_cursor`. + fields: { + offset, + nextOffset, + complete: nextOffset >= allNodes.length, + totalNodes: allNodes.length, + }, + extra: {}, + fieldErrors: {}, + }, + ]; + const envelope = { + kind: "devupFastSnapshotEnvelope", + schemaVersion: 1, + source: { fileKey: figma.fileKey || "", rootId: envelopeRootId }, + snapshot: { + fileKey: figma.fileKey || "", + version: null, + rootIds: roots.map((root) => root.id), + nodes, + diagnostics: [], + }, + resources: resourceBlock, + // No `pagination` mirror: the __DEVUP_SNAPSHOT_CURSOR__ marker node is the + // single source of truth for page state, and duplicating it is exactly how + // the two copies drifted apart before. + integrity: { + nodeCount: nodes.length, + variableRefCount: $variableRefCount, + styleRefCount: $styleRefCount, + utf8Bytes: 0, + }, + }; + // Writing the byte count into the envelope changes the envelope's own + // length, so iterate to the fixed point. `utf8ByteLength` measures without + // building a throwaway byte array. + let bytes = 0; + for (let attempt = 0; attempt < 8; attempt += 1) { + bytes = utf8ByteLength(JSON.stringify(envelope)); + if (envelope.integrity.utf8Bytes === bytes) break; + envelope.integrity.utf8Bytes = bytes; + } + if (envelope.integrity.utf8Bytes !== utf8ByteLength(JSON.stringify(envelope))) { + throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); + } + return { envelope, bytes }; } -if (envelopeBytes.length > MAX_TEXT_ENVELOPE_BYTES) { - // The byte budget above is sized to stay under this safety margin; a - // single misbehaving node (huge boundVariables/componentProperties tree) - // is the only way to reach here. Surface it as a hard error instead of - // silently falling back to an unsupported binary transport. - throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); + +// The node budget alone can't bound the envelope: a page also carries every +// variable/style its nodes reference, and that block is only sized once the +// nodes are chosen. So pack, build, and if the whole envelope overshoots the +// text limit, halve the node budget and try again. Fewer nodes can only +// reference fewer resources, so this converges. +let nodeBudget = maxPayloadBytes - 1024; +let built = null; +for (let attempt = 0; attempt < 5; attempt += 1) { + const pageNodes = packPage(nodeBudget); + if (pageNodes.length === 0) throw new Error("DEVUP_SNAPSHOT_RANGE_INVALID"); + const candidate = buildEnvelope(pageNodes, await collectResources(pageNodes)); + if (candidate.bytes <= MAX_TEXT_ENVELOPE_BYTES) { + built = candidate; + break; + } + if (pageNodes.length === 1) { + // A single node whose own resources blow the limit; no smaller page + // exists and there is no binary transport to fall back to. + throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); + } + nodeBudget = Math.floor(nodeBudget / 2); } -return envelope; +if (!built) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); +return built.envelope; diff --git a/crates/devup-mcp-figma/src/scripts/snapshot.js b/crates/devup-mcp-figma/src/scripts/snapshot.js index bd76931..9278f1a 100644 --- a/crates/devup-mcp-figma/src/scripts/snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/snapshot.js @@ -211,7 +211,10 @@ const nextOffset = Math.min(allNodes.length, offset + nodes.length); nodes.push({ id: "__DEVUP_SNAPSHOT_CURSOR__", type: "DEVUP_INTERNAL", + // Same marker shape as the fast snapshot so both paths go through the one + // `read_snapshot_cursor` reader in Rust. fields: { + offset, nextOffset, complete: nextOffset >= allNodes.length, totalNodes: allNodes.length, diff --git a/crates/devup-mcp-figma/src/snapshot.rs b/crates/devup-mcp-figma/src/snapshot.rs index 90445a3..a5dfdfe 100644 --- a/crates/devup-mcp-figma/src/snapshot.rs +++ b/crates/devup-mcp-figma/src/snapshot.rs @@ -89,6 +89,76 @@ impl RawNode { } } +/// Sentinel node ID every paginating snapshot script appends to report where +/// the next page starts. +pub const SNAPSHOT_CURSOR_ID: &str = "__DEVUP_SNAPSHOT_CURSOR__"; + +/// Page state carried by the `__DEVUP_SNAPSHOT_CURSOR__` marker node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotCursor { + pub offset: usize, + pub next_offset: usize, + pub complete: bool, + pub total_nodes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapshotCursorError { + Duplicated, + Shape, +} + +impl SnapshotCursorError { + pub fn korean_message(self) -> &'static str { + match self { + Self::Duplicated => "Figma snapshot 응답에 cursor가 중복되었습니다.", + Self::Shape => "Figma snapshot cursor 형식이 올바르지 않습니다.", + } + } + + pub fn category(self) -> &'static str { + match self { + Self::Duplicated => "cursorMultiplicity", + Self::Shape => "cursorShape", + } + } +} + +/// Reads the page cursor out of a node list without mutating it. +/// +/// Both the legacy cursor collector and the fast envelope decoder go through +/// here so the marker is parsed against exactly one field list - the two used +/// to keep separate lists, and drifted apart. +pub fn read_snapshot_cursor( + nodes: &[RawNode], +) -> Result, SnapshotCursorError> { + let markers = nodes + .iter() + .filter(|node| node.id == SNAPSHOT_CURSOR_ID) + .collect::>(); + let marker = match markers.as_slice() { + [] => return Ok(None), + [marker] => *marker, + _ => return Err(SnapshotCursorError::Duplicated), + }; + if marker.node_type != "DEVUP_INTERNAL" { + return Err(SnapshotCursorError::Shape); + } + let view = marker.typed_view(); + let index = |field: &str| { + view.value(field) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(SnapshotCursorError::Shape) + }; + Ok(Some(SnapshotCursor { + offset: index("offset")?, + next_offset: index("nextOffset")?, + complete: view.bool("complete").ok_or(SnapshotCursorError::Shape)?, + total_nodes: index("totalNodes")?, + })) +} + #[derive(Debug, Clone, Copy)] pub struct TypedNode<'a> { node: &'a RawNode, diff --git a/crates/devup-mcp-figma/tests/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index 539705a..5f3f183 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -54,7 +54,7 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu "fileKey":"FileKey123","version":"v1","rootIds":["1:1"], "nodes":[ serde_json::to_value(snapshot().nodes["1:1"].clone()).unwrap(), - {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} ],"diagnostics":[] }), }, diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 78b082e..962ab64 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -1350,7 +1350,7 @@ fn node_snapshot_follows_the_compiled_cursor_until_complete() { "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], "nodes": [ {"id": "1:2", "type": "FRAME", "fields": {"name": "Root", "childrenIds": ["1:3"]}, "extra": {}, "fieldErrors": {}}, - {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"nextOffset": 1, "complete": false, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} + {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"offset":0,"nextOffset": 1, "complete": false, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} ], "diagnostics": [] }), }, @@ -1374,7 +1374,7 @@ fn node_snapshot_follows_the_compiled_cursor_until_complete() { "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], "nodes": [ {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "완료", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, - {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"nextOffset": 2, "complete": true, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} + {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"offset":0,"nextOffset": 2, "complete": true, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} ], "diagnostics": [] }), }, diff --git a/crates/devup-mcp-figma/tests/large_values.rs b/crates/devup-mcp-figma/tests/large_values.rs index f93bbc0..7623579 100644 --- a/crates/devup-mcp-figma/tests/large_values.rs +++ b/crates/devup-mcp-figma/tests/large_values.rs @@ -103,7 +103,7 @@ fn collector_resolves_every_descriptor_before_completing_the_snapshot() { "fileKey":"FileKey123","version":"v1","rootIds":["1:2"], "nodes":[ {"id":"1:2","type":"TEXT","fields":{"characters":{"$largeValue":descriptor()}},"extra":{},"fieldErrors":{}}, - {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} ],"diagnostics":[] }), }, @@ -171,7 +171,7 @@ fn collector_marks_large_value_as_unsupported_when_upstream_rejects_continuation "fileKey":"FileKey123","version":"v1","rootIds":["1:2"], "nodes":[ {"id":"1:2","type":"TEXT","fields":{"characters":{"$largeValue":descriptor()}},"extra":{},"fieldErrors":{}}, - {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} + {"id":"__DEVUP_SNAPSHOT_CURSOR__","type":"DEVUP_INTERNAL","fields":{"offset":0,"nextOffset":1,"complete":true,"totalNodes":1},"extra":{},"fieldErrors":{}} ],"diagnostics":[] }), }, diff --git a/crates/devup-mcp-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index 6e74d68..9a3fbda 100644 --- a/crates/devup-mcp-figma/tests/upstream_contract.rs +++ b/crates/devup-mcp-figma/tests/upstream_contract.rs @@ -341,13 +341,38 @@ fn fast_snapshot_is_paginated_manifest_scoped_and_read_only() { assert_eq!(call.tool_name(), "use_figma"); assert!(code.contains("figma.getNodeByIdAsync")); - // Item A: node property collection no longer walks the prototype chain - // (that only remains for variable/style *resource* serialization, which - // has no manifest) and never buckets unlisted fields into "extra" — only - // the checked-in manifest is ever collected for a node. + // Node property collection no longer walks the prototype chain (that only + // remains for variable/style *resource* serialization, which has no + // manifest) and never buckets unlisted fields into "extra" — only the + // checked-in manifest is ever collected for a node. assert!(code.contains("for (const name of manifest)")); assert!(!code.contains("(manifestSet.has(name) ? fields : extra)")); assert!(!code.contains("const manifestSet = new Set(manifest)")); + // Default-valued fields are dropped; the tables must stay in sync with + // `devup-mcp-devup-ui/tests/default_omission_golden.rs`. + assert!(code.contains("const SCALAR_DEFAULTS = new Map([")); + assert!(code.contains(r#"const NULL_SENSITIVE_FIELDS = new Set(["maxWidth", "maxHeight"]);"#)); + // Presence-sensitive fields must never appear in the omission table. + for presence_sensitive in [ + "[\"opacity\"", + "[\"visible\"", + "[\"layoutPositioning\"", + "[\"topLeftRadius\"", + "[\"strokeWeight\"", + ] { + assert!( + !code.contains(presence_sensitive), + "{presence_sensitive} must not be omittable" + ); + } + // One serializer now covers both node fields and resources. + assert!(!code.contains("function serializeResource(")); + assert!(!code.contains("function resourcePropertyNames(")); + // Byte length is measured without building a throwaway byte array. + assert!(!code.contains("function utf8Encode(")); + assert!(code.contains("utf8ByteLength(JSON.stringify(envelope))")); + // The cursor marker is the only page-state carrier; no `pagination` mirror. + assert!(!code.contains("pagination:")); assert!(code.contains("getStyledTextSegments(textSegmentManifest)")); for field in [ "strokeTopWeight", @@ -360,9 +385,13 @@ fn fast_snapshot_is_paginated_manifest_scoped_and_read_only() { assert!(code.contains("getVariableByIdAsync")); assert!(code.contains("getVariableCollectionByIdAsync")); assert!(code.contains("getStyleByIdAsync")); - assert!(code.contains("Promise.all([...variableJobs, ...styleJobs])")); + assert!(code.contains("async function collectResources(nodes)")); assert!(code.contains("usedVariableIds")); assert!(code.contains("usedStyleIds")); + // A page carries the resources its nodes reference, so the envelope is + // only bounded once both are built - the script must shrink the page and + // retry rather than emit an oversized envelope. + assert!(code.contains("nodeBudget = Math.floor(nodeBudget / 2)")); // Item B: PNG-chunked binary transport is gone entirely — text only, // dynamically byte-budgeted and cursor-paginated like the legacy path. assert!(!code.contains("duVp")); @@ -377,12 +406,23 @@ fn fast_snapshot_is_paginated_manifest_scoped_and_read_only() { // be emitted. `offset` in particular is what distinguishes a first page // from a continuation page in `envelope.rs::peek_page_cursor`; omitting // it silently downgraded the whole fast path to legacy collection. - assert!(code.contains("fields: { offset, nextOffset, complete, totalNodes: allNodes.length }")); - assert!(code.contains("MAX_ENVELOPE_BYTES")); + for cursor_field in [ + " offset,", + " nextOffset,", + " complete: nextOffset >= allNodes.length,", + " totalNodes: allNodes.length,", + ] { + assert!( + code.contains(cursor_field), + "cursor marker must emit {cursor_field}" + ); + } + // The 15KB text limit is the only envelope ceiling left; the old 1MB + // companion check could never fire ahead of it. assert!(code.contains("MAX_TEXT_ENVELOPE_BYTES")); + assert!(!code.contains("MAX_ENVELOPE_BYTES")); assert!(code.contains("devupFastSnapshotEnvelope")); assert!(code.contains("DEVUP_TARGET_IS_SECTION")); - assert!(code.contains("0xfffd")); assert!(!code.contains("DEVUP_FIELD_VALUE_TRUNCATED")); assert!(!code.contains("MAX_INLINE_FIELD_BYTES")); assert!(!code.contains("devupLargeValueDescriptor")); From 14a6eaad372a6e273260b63cf07e57885d10e71f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 14:40:11 +0900 Subject: [PATCH 17/69] fix(devup-ui): keep a paint's opacity when formatting its colour Figma splits a translucent solid across `color.a` and the paint's own `opacity`; the effective alpha is the product. Two paths formatted `paint["color"]` directly and so silently dropped `opacity`, rendering the fill fully opaque: - `style.rs::uniform_asset_color`, which resolves the `bg` of a masked SVG asset from its descendants - `compat.rs::color_hex`, used for the Code Connect mask `bg` and the hover/active variant colour map Both now go through the paint, matching `color_from_paint`, which the non-asset path (`first_solid_color`) already used. Caught on real data: `3997:47766` (the speech-bubble tail on `A : STORY-SUBSEL`) has fill rgb(0.2388, 0.0647, 0.0647) at `opacity: 0.85` and rendered as `#3D1010` instead of `#3D1010D9`. The bubble body `3997:47760` carries the byte-identical paint and already rendered `#3D1010D9`, so the two paths disagreed on the same input. `paint_opacity_golden.rs` pins all three properties: the 0.85 case, the opaque case (which must not grow a redundant `FF`), and agreement between the asset path and the plain-fill path across four opacities. No golden moved: none of the 268 plugin-parity snapshots contains a raw-hex `bg` -- both `maskImage` fixtures resolve theirs to a `$token` -- so this path was unpinned by the corpus. End-to-end on node 3997:47749 the generated TSX changes by exactly one line, `bg="#3D1010" -> bg="#3D1010D9"`, with collection, fidelity and diagnostics otherwise unchanged. --- .../devup-mcp-devup-ui/src/codegen/compat.rs | 29 ++- .../devup-mcp-devup-ui/src/codegen/style.rs | 9 +- .../tests/paint_opacity_golden.rs | 185 ++++++++++++++++++ 3 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/compat.rs b/crates/devup-mcp-devup-ui/src/codegen/compat.rs index d858b51..09d961a 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/compat.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/compat.rs @@ -357,18 +357,17 @@ pub fn render_viewport_component(input: &Value) -> Option { prop.clone() }; let extra = if asset_components { - let color = children + let paint = children .first()? .get("children")? .as_array()? .first()? .get("fills")? .as_array()? - .first()? - .get("color")?; + .first()?; format!( " bg=\"{}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"", - color_hex(color)? + color_hex(paint)? ) } else { format!(" src={{{{\n{variant_lines}\n }}[{index}]}}") @@ -516,8 +515,8 @@ pub fn render_responsive_component_mock(input: &Value) -> Option { continue; } let variant = variants.get(&variant_key)?.as_str()?; - let color = child.get("fills")?.as_array()?.first()?.get("color")?; - colors.insert(variant.to_owned(), Value::String(color_hex(color)?)); + let paint = child.get("fills")?.as_array()?.first()?; + colors.insert(variant.to_owned(), Value::String(color_hex(paint)?)); } root_props.insert( selector.to_owned(), @@ -559,14 +558,28 @@ fn normalize_prop_name(value: &str) -> String { result } -fn color_hex(color: &Value) -> Option { +/// Formats a Figma **paint** (not a bare colour) as CSS hex. +/// +/// Takes the whole paint because Figma splits a translucent solid across +/// `color.a` and the paint's own `opacity`; the effective alpha is the product. +/// Reading `color` alone drops `opacity` and renders the fill opaque. +fn color_hex(paint: &Value) -> Option { + let color = paint.get("color")?; let channel = |name: &str| Some((color.get(name)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8); - let value = format!( + let alpha = color.get("a").and_then(Value::as_f64).unwrap_or(1.0) + * paint.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + let mut value = format!( "#{:02X}{:02X}{:02X}", channel("r")?, channel("g")?, channel("b")? ); + if alpha < 1.0 { + value.push_str(&format!( + "{:02X}", + (alpha.clamp(0.0, 1.0) * 255.0).round() as u8 + )); + } Some(value) } diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index d78ee11..cfc1668 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -154,7 +154,14 @@ fn uniform_asset_color(snapshot: &Snapshot, node: &RawNode) -> Option { if paint.get("type").and_then(Value::as_str) != Some("SOLID") { return false; } - let Some(color) = paint.get("color").and_then(color_from) else { + // Must go through `color_from_paint`, not `color_from` on the + // raw `color`: Figma splits a translucent solid across + // `color.a` and the paint's own `opacity`, and the effective + // alpha is the product. Formatting `color` alone silently + // drops `opacity` and renders the asset fully opaque, which + // also made this path disagree with `first_solid_color` on + // byte-identical input. + let Some(color) = color_from_paint(paint) else { return false; }; colors.push(color); diff --git a/crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs b/crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs new file mode 100644 index 0000000..74d4c37 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/paint_opacity_golden.rs @@ -0,0 +1,185 @@ +//! Regression: a Figma SOLID paint's `opacity` must survive into the emitted +//! colour on **every** path, including the masked-asset path. +//! +//! Figma expresses a translucent solid two different ways: alpha inside +//! `color.a`, and a separate `opacity` on the paint. The effective alpha is the +//! product. `color_from_paint` does that multiplication; formatting +//! `paint["color"]` directly does not, and silently drops `opacity`. +//! +//! The nodes below are the real `3997:47765` / `3997:47766` pair captured +//! read-only from `85CgSws3o5XsLv7aAwWJyS` (the speech-bubble tail on +//! `A : STORY-SUBSEL`). Its VECTOR fill is rgb(0.2388, 0.0647, 0.0647) at +//! `opacity: 0.85`, i.e. `#3D1010` at 85% => `#3D1010D9`. The same paint on the +//! bubble body (`3997:47760`, not an asset) already rendered as `#3D1010D9`, +//! so the two paths disagreed on identical input. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_node}; +use devup_mcp_figma::{RawNode, SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +/// The masked-asset wrapper: `isAsset`, no fills of its own, one VECTOR child +/// that carries the colour. +fn mask_asset_node() -> Value { + json!({ + "id": "3997:47765", + "type": "FRAME", + "fields": { + "childrenIds": ["3997:47766"], + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "height": 10, + "isAsset": true, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 1321315298", + "visible": true, + "width": 40, + "x": 200, + "y": 136 + } + }) +} + +/// The VECTOR that owns the paint. `paint_opacity` is the only thing varied. +fn vector_child(paint_opacity: Value) -> Value { + json!({ + "id": "3997:47766", + "type": "VECTOR", + "fields": { + "parentId": "3997:47765", + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "fills": [{ + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.064_670_071_005_821_23, + "g": 0.064_670_071_005_821_23, + "r": 0.238_782_152_533_531_2 + }, + "opacity": paint_opacity, + "type": "SOLID", + "visible": true + }], + "height": 10, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Vector 13", + "strokeAlign": "CENTER", + "visible": true, + "width": 10, + "x": 0, + "y": 0 + } + }) +} + +/// A plain (non-asset) frame carrying the *same* paint directly. This is the +/// path that was already correct, and is what the asset path must agree with. +fn plain_frame_with_same_paint(paint_opacity: Value) -> Value { + json!({ + "id": "plain:1", + "type": "FRAME", + "fields": { + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "fills": [{ + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.064_670_071_005_821_23, + "g": 0.064_670_071_005_821_23, + "r": 0.238_782_152_533_531_2 + }, + "opacity": paint_opacity, + "type": "SOLID", + "visible": true + }], + "height": 10, + "layoutMode": "NONE", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Plain", + "visible": true, + "width": 40, + "x": 0, + "y": 0 + } + }) +} + +fn tsx(root_id: &str, nodes: Vec) -> String { + let nodes = nodes + .into_iter() + .map(|node| serde_json::from_value::(node).expect("node deserializes")) + .collect::>(); + let snapshot = merge_chunks(vec![SnapshotChunk { + file_key: "85CgSws3o5XsLv7aAwWJyS".to_owned(), + version: None, + root_ids: vec![root_id.to_owned()], + nodes, + diagnostics: Vec::new(), + }]) + .expect("snapshot merges"); + generate_node(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen succeeds") + .tsx +} + +fn mask_tsx(paint_opacity: Value) -> String { + tsx( + "3997:47765", + vec![mask_asset_node(), vector_child(paint_opacity)], + ) +} + +/// Extracts the single `bg="..."` value so a failure reports the colour, not a +/// whole JSX blob. +fn bg_value(tsx: &str) -> String { + let start = tsx.find("bg=\"").expect("emitted a bg prop") + 4; + let rest = &tsx[start..]; + let end = rest.find('"').expect("bg prop terminates"); + rest[..end].to_owned() +} + +#[test] +fn masked_asset_bg_keeps_the_paint_opacity() { + let bg = bg_value(&mask_tsx(json!(0.850_000_023_841_785_9))); + assert_eq!( + bg, "#3D1010D9", + "0.85 paint opacity must survive as the alpha byte (0.85 * 255 = 217 = 0xD9); \ + dropping it renders the speech-bubble tail fully opaque" + ); +} + +#[test] +fn masked_asset_bg_omits_the_alpha_byte_when_the_paint_is_opaque() { + let bg = bg_value(&mask_tsx(json!(1.0))); + assert_eq!( + bg, "#3D1010", + "an opaque paint must not grow a redundant FF alpha byte" + ); +} + +#[test] +fn the_asset_path_and_the_plain_path_agree_on_the_same_paint() { + for opacity in [json!(1.0), json!(0.85), json!(0.5), json!(0.1)] { + let masked = bg_value(&mask_tsx(opacity.clone())); + let plain = bg_value(&tsx( + "plain:1", + vec![plain_frame_with_same_paint(opacity.clone())], + )); + assert_eq!( + masked, plain, + "identical paint (opacity {opacity}) must produce the identical colour \ + whether it is read through the masked-asset path or a plain fill" + ); + } +} From 70285327d1bd3803b355e0673ba997e7ae9344bf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 14:50:01 +0900 Subject: [PATCH 18/69] fix(devup-ui): only report an effect fallback when an effect is actually lost `DEVUP_CODEGEN_EFFECT_FALLBACK` fired whenever a node merely *had* a non-empty `effects` array, without asking whether those effects converted. Its sibling `DEVUP_CODEGEN_ABSOLUTE_FALLBACK` already guards itself with `!absolute_layout_is_exact(..)`; the effect arm had no equivalent. Because a drop shadow appears on nearly every real design, this pinned `quality.projection` to `lossy` -- and so `status` to `partial` -- for practically any input, and made `strict: true` unusable. The signal said "something was lost" on nodes where nothing was. `style::effects_are_exact` now mirrors `push_effects` case for case: - DROP_SHADOW / INNER_SHADOW are exact when offset, radius and colour parse, the blend mode is NORMAL, and (on Text, whose `text-shadow` has no spread slot) the spread is zero - LAYER_BLUR / BACKGROUND_BLUR are exact only when a radius is present; `push_effects` reads it with `unwrap_or(0.0)`, so a missing radius is silently fabricated into `blur(0px)` - GLASS is flattened to a plain backdrop blur, NOISE / TEXTURE become a no-op filter placeholder, and unknown types are dropped -- all still reported - invisible effects are skipped, matching `push_effects` Caught on real data: `3997:47759` on `A : STORY-SUBSEL` carries a BACKGROUND_BLUR and a DROP_SHADOW that both convert exactly, to `backdropFilter="blur(8px)"` and `boxShadow="0 4px 12px 0 #0000001A"`, yet was reported lossy. The pre-existing `records_explicit_diagnostics_for_unsupported_visuals` passes unchanged: its fixture is `{"type": "BACKGROUND_BLUR"}` with no radius, which is exactly the fabricated-blur case above. That test is what surfaced the missing radius guard. End-to-end on node 3997:47749 the generated TSX is byte-identical; only the quality signal moves, `projection: lossy -> approximated` and `impacts.lossy: 1 -> 0`. The genuine ABSOLUTE_FALLBACK on `3997:47757` is untouched. --- .../src/codegen/component.rs | 3 +- .../devup-mcp-devup-ui/src/codegen/style.rs | 76 +++++ .../tests/effect_fidelity_golden.rs | 264 ++++++++++++++++++ 3 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index 1384c6f..8e4638b 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -1433,7 +1433,8 @@ fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut C ( view.value("effects") .and_then(serde_json::Value::as_array) - .is_some_and(|effects| !effects.is_empty()), + .is_some_and(|effects| !effects.is_empty()) + && !style::effects_are_exact(&view), "DEVUP_CODEGEN_EFFECT_FALLBACK", "일부 Figma effect는 계산된 CSS로 변환되지 않을 수 있습니다.", FidelityImpact::Lossy, diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index cfc1668..630dd01 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -961,6 +961,82 @@ fn push_effects(view: &TypedNode<'_>, component: &str, props: &mut Vec) { } } +/// Whether every visible effect on this node survives `push_effects` without +/// loss. Mirrors that function case for case; the two must move together. +/// +/// `DEVUP_CODEGEN_EFFECT_FALLBACK` used to fire whenever a node merely *had* an +/// effects array. A plain drop shadow is present on nearly every real design, +/// so that permanently pinned `projection` to `lossy` and made `strict: true` +/// unusable, while saying nothing about what was actually lost. +/// +/// Deliberately *not* counted as loss: `showShadowBehindNode`. CSS always +/// paints a non-inset `box-shadow` behind the element's box, so the flag only +/// changes rendering behind a translucent fill. Treating it as loss would put +/// essentially every Figma shadow back into `lossy` for a difference that is +/// usually invisible, recreating the problem this guard removes. +pub(super) fn effects_are_exact(view: &TypedNode<'_>) -> bool { + let Some(effects) = view.value("effects").and_then(Value::as_array) else { + return true; + }; + // `push_effects` picks `textShadow` for Text, which has no spread slot. + // `component.rs` resolves exactly this node type to the `Text` component. + let is_text = view.node_type() == "TEXT"; + let visible = effects + .iter() + .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) + .collect::>(); + + // `push_effects` writes `filter` once per effect that maps to it, so two + // such effects would collide on a single prop and the later one wins. + let filter_writers = visible + .iter() + .filter(|effect| { + matches!( + effect.get("type").and_then(Value::as_str), + Some("LAYER_BLUR" | "NOISE" | "TEXTURE") + ) + }) + .count(); + if filter_writers > 1 { + return false; + } + + visible + .iter() + .all(|effect| match effect.get("type").and_then(Value::as_str) { + Some("DROP_SHADOW" | "INNER_SHADOW") => { + // Same fields `push_effects` requires before it emits a shadow; + // if any is missing the effect is dropped on the floor. + let renders = effect + .get("offset") + .and_then(|offset| { + Some((offset.get("x")?.as_f64()?, offset.get("y")?.as_f64()?)) + }) + .is_some() + && effect.get("radius").and_then(Value::as_f64).is_some() + && effect.get("color").and_then(color_from).is_some(); + // CSS shadows carry no per-shadow blend mode. + let blend_survives = effect + .get("blendMode") + .and_then(Value::as_str) + .is_none_or(|mode| mode == "NORMAL"); + // `text-shadow` has no spread component. + let spread_survives = + !is_text || effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0) == 0.0; + renders && blend_survives && spread_survives + } + // `push_effects` falls back to `blur(0px)` when the radius is + // missing or unparseable, which silently fabricates the blur away. + Some("LAYER_BLUR" | "BACKGROUND_BLUR") => { + effect.get("radius").and_then(Value::as_f64).is_some() + } + // `GLASS` is flattened to a plain backdrop blur, `NOISE`/`TEXTURE` + // become a no-op filter placeholder, and any other type is silently + // ignored. All of those are real losses. + _ => false, + }) +} + fn zero_or_px(value: f64) -> String { if value == 0.0 { "0".to_owned() diff --git a/crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs b/crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs new file mode 100644 index 0000000..68242b0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/effect_fidelity_golden.rs @@ -0,0 +1,264 @@ +//! `DEVUP_CODEGEN_EFFECT_FALLBACK` must describe what actually happened. +//! +//! The diagnostic used to fire whenever a node merely *had* an `effects` +//! array, without asking whether those effects converted. Because a drop +//! shadow is ubiquitous, that made `projection: lossy` -- and therefore +//! `status: partial` -- unavoidable for essentially every real design, which +//! in turn made `strict: true` unusable. +//! +//! The first test is the real `3997:47759` node from `A : STORY-SUBSEL` +//! (`85CgSws3o5XsLv7aAwWJyS`): a `BACKGROUND_BLUR` plus a `DROP_SHADOW`, both +//! of which `push_effects` converts exactly, to +//! `backdropFilter="blur(8px)"` and `boxShadow="0 4px 12px 0 #0000001A"`. +//! +//! The remaining tests pin the effects that genuinely cannot be expressed, so +//! tightening the guard cannot silently under-report real infidelity. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_node}; +use devup_mcp_figma::{RawNode, SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn frame_with_effects(effects: Value) -> Value { + json!({ + "id": "node:1", + "type": "FRAME", + "fields": { + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "effects": effects, + "height": 146, + "layoutMode": "VERTICAL", + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "maxHeight": null, + "maxWidth": null, + "name": "Frame 1321315031", + "visible": true, + "width": 240, + "x": 0, + "y": 0 + } + }) +} + +fn text_with_effects(effects: Value) -> Value { + json!({ + "id": "node:1", + "type": "TEXT", + "fields": { + "characters": "shadowed", + "constraints": { "horizontal": "MIN", "vertical": "MIN" }, + "effects": effects, + "fontName": { "family": "Pretendard", "style": "Regular" }, + "fontSize": 15, + "height": 24, + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "HUG", + "layoutSizingVertical": "HUG", + "maxHeight": null, + "maxWidth": null, + "name": "shadowed", + "textAutoResize": "WIDTH_AND_HEIGHT", + "visible": true, + "width": 80, + "x": 0, + "y": 0 + } + }) +} + +fn drop_shadow(extra: Value) -> Value { + let mut shadow = json!({ + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { "a": 0.100_000_001_490_116_12, "b": 0, "g": 0, "r": 0 }, + "offset": { "x": 0, "y": 4 }, + "radius": 12, + "showShadowBehindNode": false, + "spread": 0, + "type": "DROP_SHADOW", + "visible": true + }); + let object = shadow.as_object_mut().expect("shadow is an object"); + for (key, value) in extra.as_object().expect("extra is an object") { + object.insert(key.clone(), value.clone()); + } + shadow +} + +const BACKGROUND_BLUR: fn() -> Value = || { + json!({ + "blurType": "NORMAL", + "boundVariables": {}, + "radius": 8, + "type": "BACKGROUND_BLUR", + "visible": true + }) +}; + +struct Rendered { + tsx: String, + reported_lossy: bool, +} + +fn render(node: Value) -> Rendered { + let node = serde_json::from_value::(node).expect("node deserializes"); + let snapshot = merge_chunks(vec![SnapshotChunk { + file_key: "85CgSws3o5XsLv7aAwWJyS".to_owned(), + version: None, + root_ids: vec!["node:1".to_owned()], + nodes: vec![node], + diagnostics: Vec::new(), + }]) + .expect("snapshot merges"); + let output = + generate_node(&snapshot, "node:1", &CodegenOptions::default()).expect("codegen succeeds"); + let reported_lossy = output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "DEVUP_CODEGEN_EFFECT_FALLBACK"); + Rendered { + tsx: output.tsx, + reported_lossy, + } +} + +#[test] +fn effects_that_convert_exactly_are_not_reported_lossy() { + let rendered = render(frame_with_effects(json!([ + BACKGROUND_BLUR(), + drop_shadow(json!({})) + ]))); + + // Both effects really did land in the output, so the claim below is about + // a converted node rather than an empty one. + assert!( + rendered.tsx.contains(r#"backdropFilter="blur(8px)""#), + "BACKGROUND_BLUR should convert; got:\n{}", + rendered.tsx + ); + assert!( + rendered + .tsx + .contains(r#"boxShadow="0 4px 12px 0 #0000001A""#), + "DROP_SHADOW should convert; got:\n{}", + rendered.tsx + ); + assert!( + !rendered.reported_lossy, + "both effects converted exactly, so EFFECT_FALLBACK must not fire -- \ + otherwise any design with a shadow can never reach status=complete" + ); +} + +#[test] +fn a_lone_layer_blur_is_not_reported_lossy() { + let rendered = render(frame_with_effects(json!([{ + "radius": 4, "type": "LAYER_BLUR", "visible": true + }]))); + assert!(rendered.tsx.contains(r#"filter="blur(4px)""#)); + assert!(!rendered.reported_lossy); +} + +#[test] +fn a_blur_without_a_radius_is_reported_lossy() { + // `push_effects` reads the radius with `unwrap_or(0.0)`, so a missing one + // is silently fabricated into `blur(0px)` -- the blur is gone, not converted. + assert!( + render(frame_with_effects(json!([{ + "type": "BACKGROUND_BLUR", "visible": true + }]))) + .reported_lossy + ); + assert!( + render(frame_with_effects(json!([{ + "type": "LAYER_BLUR", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn noise_is_still_reported_lossy() { + // Converted to a no-op `contrast(100%) brightness(100%)` placeholder. + assert!( + render(frame_with_effects(json!([{ + "type": "NOISE", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn texture_is_still_reported_lossy() { + assert!( + render(frame_with_effects(json!([{ + "type": "TEXTURE", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn glass_is_still_reported_lossy() { + // Flattened to a plain backdrop blur, which is an approximation. + assert!( + render(frame_with_effects(json!([{ + "radius": 8, "type": "GLASS", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn an_unknown_effect_type_is_still_reported_lossy() { + // Silently dropped by `push_effects`; that must stay visible. + assert!( + render(frame_with_effects(json!([{ + "radius": 8, "type": "SOME_FUTURE_EFFECT", "visible": true + }]))) + .reported_lossy + ); +} + +#[test] +fn an_invisible_unsupported_effect_is_not_reported_lossy() { + // `push_effects` skips invisible effects, so nothing was lost. + assert!( + !render(frame_with_effects(json!([{ + "type": "NOISE", "visible": false + }]))) + .reported_lossy + ); +} + +#[test] +fn a_shadow_with_a_non_normal_blend_mode_is_reported_lossy() { + // CSS box-shadow has no per-shadow blend mode. + assert!( + render(frame_with_effects(json!([drop_shadow( + json!({ "blendMode": "MULTIPLY" }) + )]))) + .reported_lossy + ); +} + +#[test] +fn a_text_shadow_that_needs_spread_is_reported_lossy() { + // `text-shadow` has no spread component, so a non-zero spread is dropped. + let rendered = render(text_with_effects(json!([drop_shadow( + json!({ "spread": 4 }) + )]))); + assert!(rendered.tsx.contains("textShadow=")); + assert!( + rendered.reported_lossy, + "spread cannot survive in text-shadow and must be reported" + ); +} + +#[test] +fn a_text_shadow_without_spread_is_not_reported_lossy() { + let rendered = render(text_with_effects(json!([drop_shadow(json!({}))]))); + assert!(rendered.tsx.contains("textShadow=")); + assert!(!rendered.reported_lossy); +} From 6f713f948c8300bb1a50a6663c2267d80a1f2c99 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:00:39 +0900 Subject: [PATCH 19/69] chore: ignore local agent state directories .omc/ and .omo/ hold per-working-copy agent session state (checkpoints, run logs). They are machine-local and must never reach the repository. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 0743a8d..0d8f70c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ .env.* !.env.example +# Agent session state (oh-my-claudecode / omo), local to a working copy +.omc/ +.omo/ + From c153721afb4d391b18551ea59ae85ab62982479c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:00:40 +0900 Subject: [PATCH 20/69] refactor: emit every diagnostic and guidance string in English These strings are returned to an LLM agent over MCP, where Korean prose costs several times the tokens of the equivalent English. Only string literals changed: comments are untouched, and Korean Figma fixture data is preserved because tests such as codegen.rs use it deliberately to exercise CJK component-name normalisation and multi-byte text-run splitting. Every assertion pinning a translated string was updated in lockstep. --- .../src/codegen/component.rs | 30 +-- .../devup-mcp-devup-ui/src/codegen/variant.rs | 6 +- crates/devup-mcp-devup-ui/src/provenance.rs | 2 +- .../src/theme/devup_json.rs | 10 +- .../src/theme/project_theme.rs | 2 +- crates/devup-mcp-devup-ui/src/ui_validate.rs | 12 +- crates/devup-mcp-devup-ui/src/validation.rs | 2 +- .../tests/compat_manifest.rs | 12 +- .../devup-mcp-devup-ui/tests/support/mod.rs | 90 +++---- crates/devup-mcp-devup-ui/tests/validation.rs | 10 +- crates/devup-mcp-figma/src/assets.rs | 24 +- crates/devup-mcp-figma/src/collector.rs | 225 +++++++++--------- crates/devup-mcp-figma/src/explore.rs | 12 +- crates/devup-mcp-figma/src/large_values.rs | 46 ++-- crates/devup-mcp-figma/src/payload.rs | 2 +- crates/devup-mcp-figma/src/search.rs | 6 +- crates/devup-mcp-figma/src/section.rs | 26 +- crates/devup-mcp-figma/src/snapshot.rs | 12 +- crates/devup-mcp-figma/src/source.rs | 26 +- crates/devup-mcp-figma/src/url.rs | 12 +- crates/devup-mcp-figma/src/variables.rs | 6 +- crates/devup-mcp-figma/tests/collector.rs | 8 +- crates/devup-mcp-figma/tests/explore.rs | 32 +-- .../tests/explore_script_behavior.mjs | 6 +- .../tests/upstream_contract.rs | 4 +- .../devup-mcp-figma/tests/used_resources.rs | 2 +- crates/devup-mcp-visual/src/lib.rs | 4 +- crates/devup-mcp-visual/src/main.rs | 12 +- crates/devup-mcp/src/server/artifacts.rs | 22 +- crates/devup-mcp/src/server/delivery.rs | 14 +- crates/devup-mcp/src/server/handoff.rs | 28 +-- crates/devup-mcp/src/server/output.rs | 52 ++-- .../devup-mcp/src/server/project_context.rs | 16 +- crates/devup-mcp/src/server/project_root.rs | 2 +- crates/devup-mcp/src/server/projection.rs | 66 ++--- crates/devup-mcp/src/server/stack_diff.rs | 30 +-- crates/devup-mcp/src/server/validation.rs | 18 +- crates/devup-mcp/tests/figma_explore.rs | 6 +- crates/devup-mcp/tests/ground_truth_tools.rs | 2 +- crates/devup-mcp/tests/handoff.rs | 4 +- crates/devup-mcp/tests/resource_delivery.rs | 2 +- crates/devup-mcp/tests/section_export.rs | 6 +- 42 files changed, 460 insertions(+), 449 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index 8e4638b..87cdc44 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -59,7 +59,7 @@ pub fn generate_component( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 변환할 node를 찾지 못했습니다.", + "Node to convert was not found in the Figma snapshot.", false, ) })?; @@ -98,7 +98,7 @@ pub fn generate_legacy_component( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 변환할 node를 찾지 못했습니다.", + "Node to convert was not found in the Figma snapshot.", false, ) })?; @@ -179,7 +179,7 @@ pub fn generate_component_set_target( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 component set을 찾지 못했습니다.", + "Component set was not found in the Figma snapshot.", false, ) })?; @@ -229,7 +229,7 @@ pub fn generate_component_set_target( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("component set에서 '{target_name}' 출력을 찾지 못했습니다."), + format!("Output '{target_name}' was not found in the component set."), false, ) })?; @@ -282,14 +282,14 @@ pub fn generate_inlined_component_instance( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "inline instance root를 찾지 못했습니다.", + "Inline instance root was not found.", false, ) })?; let instance = snapshot.nodes.get(instance_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "inline할 component instance를 찾지 못했습니다.", + "Component instance to inline was not found.", false, ) })?; @@ -320,7 +320,7 @@ pub fn generate_inlined_component_instance( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("'{name}' component set을 찾지 못했습니다."), + format!("Component set '{name}' was not found."), false, ) })?; @@ -341,7 +341,7 @@ pub fn generate_inlined_component_instance( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("'{name}' instance variant를 찾지 못했습니다."), + format!("Instance variant '{name}' was not found."), false, ) })?; @@ -431,7 +431,7 @@ pub fn render_component_registration_snapshot( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "component registration root를 찾지 못했습니다.", + "Component registration root was not found.", false, ) })?; @@ -453,7 +453,7 @@ pub fn render_component_registration_snapshot( .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("registration 대상 '{target_name}'을 찾지 못했습니다."), + format!("Registration target '{target_name}' was not found."), false, ) })? @@ -963,7 +963,7 @@ fn generate_node_marked( let root = snapshot.nodes.get(root_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma snapshot에서 변환할 node를 찾지 못했습니다.", + "Node to convert was not found in the Figma snapshot.", false, ) })?; @@ -1046,7 +1046,7 @@ fn render_node( if !visiting.insert(node.id.clone()) { return Err(DevupError::new( ErrorCode::DevupCodegenFailed, - "Figma node 트리에 순환 참조가 있습니다.", + "Figma node tree contains a circular reference.", false, )); } @@ -1420,14 +1420,14 @@ fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut C ( view.bool("isMask") == Some(true), "DEVUP_CODEGEN_MASK_FALLBACK", - "Mask는 기본 Box 렌더링으로 보존됩니다.", + "Mask is preserved as a plain Box rendering.", FidelityImpact::Lossy, ), ( view.string("layoutPositioning") == Some("ABSOLUTE") && !layout::absolute_layout_is_exact(snapshot, node), "DEVUP_CODEGEN_ABSOLUTE_FALLBACK", - "절대 배치는 position props로 제한적으로 변환됩니다.", + "Absolute positioning is converted to position props with limited fidelity.", FidelityImpact::Approximated, ), ( @@ -1436,7 +1436,7 @@ fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut C .is_some_and(|effects| !effects.is_empty()) && !style::effects_are_exact(&view), "DEVUP_CODEGEN_EFFECT_FALLBACK", - "일부 Figma effect는 계산된 CSS로 변환되지 않을 수 있습니다.", + "Some Figma effects may not be converted into computed CSS.", FidelityImpact::Lossy, ), ]; diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant.rs b/crates/devup-mcp-devup-ui/src/codegen/variant.rs index 92088ce..6a66f37 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant.rs @@ -51,7 +51,7 @@ pub(super) fn generate_variant_component_set( let set = snapshot.nodes.get(set_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "variant component set을 찾지 못했습니다.", + "Variant component set was not found.", false, ) })?; @@ -95,7 +95,7 @@ pub(super) fn generate_variant_component_set( .ok_or_else(|| { DevupError::new( ErrorCode::DevupCodegenFailed, - "component set에 variant component가 없습니다.", + "Component set has no variant components.", false, ) })?; @@ -181,7 +181,7 @@ pub(super) fn generate_variant_component_set( .into_iter() .map(|node_id| Diagnostic { code: "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK".to_owned(), - message: "non-default variant의 중첩 차이를 default variant 구조로 대체했습니다." + message: "Nesting differences in the non-default variant were replaced with the default variant structure." .to_owned(), node_id: Some(node_id), severity: Some(DiagnosticSeverity::Warning), diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 248cc9f..dc75b88 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -285,7 +285,7 @@ pub fn validate_fidelity( { return Err(DevupError::with_details( ErrorCode::DevupCodegenFailed, - "projection trace가 source node를 정확히 한 번씩 설명하지 못했습니다.", + "Projection trace did not account for each source node exactly once.", false, json!({ "missingNodeIds": missing, diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs index e46ea93..c9c16aa 100644 --- a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs @@ -233,7 +233,7 @@ pub fn generate_devup_json( }); diagnostics.push(Diagnostic { code: "DEVUP_THEME_COLLECTION_MISSING".to_owned(), - message: format!("변수 '{}'의 collection을 찾지 못했습니다.", variable.name), + message: format!("Collection for variable '{}' was not found.", variable.name), node_id: None, severity: Some(DiagnosticSeverity::Warning), resource_kind: Some("variable".to_owned()), @@ -259,7 +259,7 @@ pub fn generate_devup_json( diagnostics.push(Diagnostic { code: "DEVUP_THEME_ALIAS_CYCLE".to_owned(), message: format!( - "변수 '{}'의 alias를 안전하게 해석하지 못했습니다.", + "Alias for variable '{}' could not be resolved safely.", variable.name ), node_id: None, @@ -371,7 +371,7 @@ pub fn generate_devup_json( diagnostics.push(Diagnostic { code: "DEVUP_THEME_TOKEN_CONFLICT".to_owned(), message: format!( - "동일한 theme token에 서로 다른 값이 있어 결정적 우선순위를 적용했습니다: token={token}, mode={mode}" + "The same theme token had conflicting values; applied deterministic precedence: token={token}, mode={mode}" ), node_id: None, severity: Some(DiagnosticSeverity::Warning), @@ -446,7 +446,7 @@ pub fn generate_devup_json( let mut output = serde_json::to_string_pretty(&Value::Object(root)).map_err(|_| { DevupError::new( ErrorCode::DevupThemeConflict, - "devup.json을 직렬화하지 못했습니다.", + "Failed to serialize devup.json.", false, ) })?; @@ -540,7 +540,7 @@ pub fn variable_snapshot_from_result( find_variable_snapshot(&result.raw).ok_or_else(|| { DevupError::new( ErrorCode::DevupThemeConflict, - "Figma MCP 응답에서 변수 snapshot을 찾지 못했습니다.", + "Variable snapshot was not found in the Figma MCP response.", false, ) }) diff --git a/crates/devup-mcp-devup-ui/src/theme/project_theme.rs b/crates/devup-mcp-devup-ui/src/theme/project_theme.rs index a821764..a250654 100644 --- a/crates/devup-mcp-devup-ui/src/theme/project_theme.rs +++ b/crates/devup-mcp-devup-ui/src/theme/project_theme.rs @@ -217,7 +217,7 @@ pub fn parse_project_theme(source: &str) -> Result { let root: Value = serde_json::from_str(source).map_err(|error| { DevupError::with_details( ErrorCode::DevupInvalidInput, - "devup.json을 JSON으로 파싱하지 못했습니다.", + "Failed to parse devup.json as JSON.", false, serde_json::json!({ "parseError": error.to_string() }), ) diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs index ce57e68..9ab3f2a 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -107,7 +107,7 @@ pub fn validate_devup_ui_tsx( rule: "invalid-syntax", severity: Severity::Error, byte_range: [start, end], - message: format!("TSX가 TypeScript+JSX 문법 검증을 통과하지 못했습니다: {diagnostic}"), + message: format!("TSX failed TypeScript+JSX syntax validation: {diagnostic}"), suggestion: None, }); } @@ -164,7 +164,7 @@ impl<'t> TsxVisitor<'t> { rule: "unknown-token", severity: Severity::Error, byte_range: [span.start as usize, span.end as usize], - message: format!("${token}은(는) devup.json에 정의되어 있지 않습니다."), + message: format!("${token} is not defined in devup.json."), suggestion: if suggestions.is_empty() { None } else { @@ -190,7 +190,7 @@ impl<'t> TsxVisitor<'t> { severity: Severity::Warning, byte_range: [span.start as usize, span.end as usize], message: format!( - "{prop_name}에 하드코딩된 색상 {text}을(를) 사용했습니다. devup.json 토큰 사용을 고려하세요." + "{prop_name} uses hardcoded color {text}. Consider using a devup.json token." ), suggestion: match suggestion { Some(tokens) if !tokens.is_empty() => Some(format!( @@ -215,7 +215,7 @@ impl<'t> TsxVisitor<'t> { severity: Severity::Warning, byte_range: [span.start as usize, span.end as usize], message: format!( - "{prop_name}에 하드코딩된 길이 {text}을(를) 사용했습니다. devup.json 토큰 사용을 고려하세요." + "{prop_name} uses hardcoded length {text}. Consider using a devup.json token." ), suggestion: match suggestion { Some(tokens) if !tokens.is_empty() => Some(format!( @@ -244,7 +244,7 @@ impl<'t> TsxVisitor<'t> { severity: Severity::Error, byte_range: [span.start as usize, span.end as usize], message: format!( - "{prop_name}은(는) {}이(가) 인식하는 prop이 아닙니다.", + "{prop_name} is not a prop recognized by {}.", self.element_stack .last() .and_then(|name| name.as_deref()) @@ -282,7 +282,7 @@ impl<'t> TsxVisitor<'t> { property.value.span().end as usize, ], message: format!( - "{call_name}({{ {key}: ... }})는 정적으로 분석 가능한 리터럴 값만 허용합니다. 변수나 표현식은 zero-runtime 추출을 깨뜨립니다." + "{call_name}({{ {key}: ... }}) accepts only statically analyzable literal values. Variables or expressions break zero-runtime extraction." ), suggestion: None, }); diff --git a/crates/devup-mcp-devup-ui/src/validation.rs b/crates/devup-mcp-devup-ui/src/validation.rs index 87f862e..a1606c0 100644 --- a/crates/devup-mcp-devup-ui/src/validation.rs +++ b/crates/devup-mcp-devup-ui/src/validation.rs @@ -49,7 +49,7 @@ pub fn validate_tsx(source: &str) -> Result { .collect::>(); Err(DevupError::with_details( ErrorCode::DevupCodegenFailed, - "생성된 DevupUI TSX가 TypeScript JSX 문법 검증을 통과하지 못했습니다.", + "Generated DevupUI TSX failed TypeScript JSX syntax validation.", false, json!({ "errorCount": errors.len(), diff --git a/crates/devup-mcp-devup-ui/tests/compat_manifest.rs b/crates/devup-mcp-devup-ui/tests/compat_manifest.rs index ef6c569..7901352 100644 --- a/crates/devup-mcp-devup-ui/tests/compat_manifest.rs +++ b/crates/devup-mcp-devup-ui/tests/compat_manifest.rs @@ -5,8 +5,9 @@ use std::path::PathBuf; #[test] fn pinned_plugin_corpus_is_complete_and_self_consistent() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/devup-figma-plugin"); - let summary = support::validate_corpus(&root) - .unwrap_or_else(|violations| panic!("compat corpus 위반:\n{}", violations.join("\n"))); + let summary = support::validate_corpus(&root).unwrap_or_else(|violations| { + panic!("compat corpus violations:\n{}", violations.join("\n")) + }); assert_eq!(summary.source_files, 54); assert_eq!(summary.ledger_entries, 978); assert_eq!(summary.cases, 268); @@ -24,8 +25,9 @@ fn manifest_hashes_are_stable_across_checkout_line_endings() { #[test] fn coverage_registry_maps_every_inventory_entry_to_real_evidence() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/devup-figma-plugin"); - let summary = support::validate_coverage_registry(&root) - .unwrap_or_else(|violations| panic!("coverage registry 위반:\n{}", violations.join("\n"))); + let summary = support::validate_coverage_registry(&root).unwrap_or_else(|violations| { + panic!("coverage registry violations:\n{}", violations.join("\n")) + }); assert_eq!(summary.inventory_entries, 978); assert_eq!(summary.snapshot_parity_entries, 252); @@ -34,6 +36,6 @@ fn coverage_registry_maps_every_inventory_entry_to_real_evidence() { assert_eq!(summary.non_parity_entries, 60); assert_eq!( summary.not_ported_entries, 0, - "모든 upstream inventory 항목은 실행 evidence 또는 명시적인 범위 분류를 가져야 합니다." + "every upstream inventory entry must have executable evidence or an explicit scope classification." ); } diff --git a/crates/devup-mcp-devup-ui/tests/support/mod.rs b/crates/devup-mcp-devup-ui/tests/support/mod.rs index ce4b80d..a715cf3 100644 --- a/crates/devup-mcp-devup-ui/tests/support/mod.rs +++ b/crates/devup-mcp-devup-ui/tests/support/mod.rs @@ -75,8 +75,8 @@ pub enum FixtureError { impl std::fmt::Display for FixtureError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Io(error) => write!(formatter, "fixture를 읽지 못했습니다: {error}"), - Self::Json(error) => write!(formatter, "fixture JSON이 올바르지 않습니다: {error}"), + Self::Io(error) => write!(formatter, "failed to read fixture: {error}"), + Self::Json(error) => write!(formatter, "fixture JSON is invalid: {error}"), Self::Invalid(message) => formatter.write_str(message), } } @@ -94,13 +94,13 @@ pub fn load_case(path: impl AsRef) -> Result { fn validate_case(case: &FixtureCase) -> Result<(), FixtureError> { if case.schema_version != 1 { return Err(FixtureError::Invalid(format!( - "지원하지 않는 fixture schemaVersion입니다: {}", + "unsupported fixture schemaVersion: {}", case.schema_version ))); } if case.id.trim().is_empty() || case.source.test_id.trim().is_empty() { return Err(FixtureError::Invalid( - "fixture id와 source.testId는 비어 있을 수 없습니다.".to_owned(), + "fixture id and source.testId must not be empty.".to_owned(), )); } if case.source.commit.len() != 40 @@ -111,7 +111,7 @@ fn validate_case(case: &FixtureCase) -> Result<(), FixtureError> { .all(|byte| byte.is_ascii_hexdigit()) { return Err(FixtureError::Invalid( - "source.commit은 40자리 git SHA여야 합니다.".to_owned(), + "source.commit must be a 40-character git SHA.".to_owned(), )); } if !case @@ -121,7 +121,7 @@ fn validate_case(case: &FixtureCase) -> Result<(), FixtureError> { .contains_key(&case.request.root_id) { return Err(FixtureError::Invalid(format!( - "rootId '{}'가 payload에 없습니다.", + "rootId '{}' is missing from the payload.", case.request.root_id ))); } @@ -155,7 +155,7 @@ pub fn run_case(case: &FixtureCase) -> Result { let variables = case.payload.variables.as_ref().ok_or_else(|| { DevupError::new( devup_mcp_figma::ErrorCode::DevupThemeConflict, - "devup-json fixture에는 variables payload가 필요합니다.", + "devup-json fixture requires a variables payload.", false, ) })?; @@ -178,7 +178,7 @@ pub fn run_case(case: &FixtureCase) -> Result { Err(error) => serde_json::to_value(error).map_err(|_| { DevupError::new( devup_mcp_figma::ErrorCode::DevupCodegenFailed, - "오류 fixture 결과를 직렬화하지 못했습니다.", + "Failed to serialize the error fixture result.", false, ) }), @@ -418,7 +418,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result Result Result Result Result {}", + "coverage evidence test symbol is missing: {} -> {}", evidence.rust_test, evidence.source_path )); } } Err(error) => violations.push(format!( - "coverage source를 읽을 수 없습니다: {}: {error}", + "cannot read coverage source: {}: {error}", evidence.source_path )), } @@ -513,7 +516,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result {} _ => violations.push(format!( - "rust_snapshot이 실행 가능한 snapshot parity test를 참조하지 않습니다: {} -> {}", + "rust_snapshot does not reference an executable snapshot parity test: {} -> {}", entry.test_id, entry.rust_test )), } @@ -524,7 +527,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result {} _ => violations.push(format!( - "rust_assertion이 등록된 대표 Rust test를 참조하지 않습니다: {} -> {}", + "rust_assertion does not reference a registered representative Rust test: {} -> {}", entry.test_id, entry.rust_test )), } @@ -537,7 +540,7 @@ pub fn validate_coverage_registry(root: &Path) -> Result { @@ -547,19 +550,19 @@ pub fn validate_coverage_registry(root: &Path) -> Result {} _ => violations.push(format!( - "비-parity 경계가 등록된 Rust contract를 참조하지 않습니다: {} -> {}", + "non-parity boundary does not reference a registered Rust contract: {} -> {}", entry.test_id, entry.rust_test )), } } LedgerClassification::Contract => violations.push(format!( - "모호한 contract 분류를 실행 evidence 또는 명시적 비-parity로 바꿔야 합니다: {}", + "ambiguous contract classification must become executable evidence or explicit non-parity: {}", entry.test_id )), } @@ -567,17 +570,17 @@ pub fn validate_coverage_registry(root: &Path) -> Result Result> { Err(error) => return Err(vec![error]), }; if manifest.schema_version != 1 || ledger.schema_version != 1 { - violations.push("manifest와 ledger schemaVersion은 1이어야 합니다.".to_owned()); + violations.push("manifest and ledger schemaVersion must be 1.".to_owned()); } if manifest.source.commit.len() != 40 || !manifest @@ -617,7 +620,7 @@ pub fn validate_corpus(root: &Path) -> Result> { .bytes() .all(|byte| byte.is_ascii_hexdigit()) { - violations.push("manifest source.commit이 40자리 git SHA가 아닙니다.".to_owned()); + violations.push("manifest source.commit is not a 40-character git SHA.".to_owned()); } if manifest.baseline.test_files != 54 || manifest.baseline.passed != 978 @@ -625,10 +628,10 @@ pub fn validate_corpus(root: &Path) -> Result> { || manifest.baseline.snapshots != 268 || manifest.baseline.assertions != 1_974 { - violations.push("고정 upstream baseline 수치가 일치하지 않습니다.".to_owned()); + violations.push("pinned upstream baseline counts do not match.".to_owned()); } if manifest.source_test_files.len() != manifest.baseline.test_files { - violations.push("source test file 수가 baseline과 일치하지 않습니다.".to_owned()); + violations.push("source test file count does not match the baseline.".to_owned()); } duplicate_values( manifest.source_test_files.iter().map(String::as_str), @@ -649,10 +652,12 @@ pub fn validate_corpus(root: &Path) -> Result> { .map(|file| file.path.clone()) .collect::>(); for path in discovered.difference(&declared) { - violations.push(format!("manifest에 없는 orphan 파일: {path}")); + violations.push(format!("orphan file missing from the manifest: {path}")); } for path in declared.difference(&discovered) { - violations.push(format!("실제로 존재하지 않는 manifest 파일: {path}")); + violations.push(format!( + "manifest file that does not actually exist: {path}" + )); } for file in &manifest.files { let path = root.join(file.path.replace('/', std::path::MAIN_SEPARATOR_STR)); @@ -660,10 +665,10 @@ pub fn validate_corpus(root: &Path) -> Result> { Ok(bytes) => { let actual = hex_sha256(&bytes); if actual != file.sha256 { - violations.push(format!("checksum 불일치: {}", file.path)); + violations.push(format!("checksum mismatch: {}", file.path)); } } - Err(error) => violations.push(format!("{} 읽기 실패: {error}", file.path)), + Err(error) => violations.push(format!("{} read failed: {error}", file.path)), } } @@ -674,7 +679,7 @@ pub fn validate_corpus(root: &Path) -> Result> { Ok(case) => { if let Some(first) = case_ids.insert(case.id.clone(), relative.clone()) { violations.push(format!( - "중복 fixture id '{}': {first}, {relative}", + "duplicate fixture id '{}': {first}, {relative}", case.id )); } @@ -686,15 +691,15 @@ pub fn validate_corpus(root: &Path) -> Result> { let mut ledger_ids = BTreeSet::new(); for entry in &ledger.entries { if !ledger_ids.insert(entry.test_id.as_str()) { - violations.push(format!("중복 ledger test id: {}", entry.test_id)); + violations.push(format!("duplicate ledger test id: {}", entry.test_id)); } if entry.source_file.trim().is_empty() || entry.rust_test.trim().is_empty() { - violations.push(format!("ledger 경로가 비어 있습니다: {}", entry.test_id)); + violations.push(format!("ledger path is empty: {}", entry.test_id)); } for fixture_id in &entry.fixture_ids { if !case_ids.contains_key(fixture_id) { violations.push(format!( - "ledger가 없는 fixture를 참조합니다: {} -> {fixture_id}", + "ledger references a missing fixture: {} -> {fixture_id}", entry.test_id )); } @@ -702,7 +707,7 @@ pub fn validate_corpus(root: &Path) -> Result> { match entry.classification { LedgerClassification::RustSnapshot if entry.fixture_ids.is_empty() => { violations.push(format!( - "rust_snapshot ledger에 fixture가 없습니다: {}", + "rust_snapshot ledger entry has no fixture: {}", entry.test_id )) } @@ -714,7 +719,10 @@ pub fn validate_corpus(root: &Path) -> Result> { .as_deref() .is_none_or(|value| value.trim().is_empty()) => { - violations.push(format!("분류 근거가 없습니다: {}", entry.test_id)); + violations.push(format!( + "classification has no rationale: {}", + entry.test_id + )); } _ => {} } @@ -724,11 +732,11 @@ pub fn validate_corpus(root: &Path) -> Result> { || manifest.counts.snapshots != snapshot_files.len() || manifest.counts.ledger_entries != ledger.entries.len() { - violations.push("manifest counts가 발견된 corpus와 일치하지 않습니다.".to_owned()); + violations.push("manifest counts do not match the discovered corpus.".to_owned()); } if ledger.entries.len() != manifest.baseline.passed { violations - .push("ledger entry 수가 upstream passing test 수와 일치하지 않습니다.".to_owned()); + .push("ledger entry count does not match the upstream passing test count.".to_owned()); } if violations.is_empty() { @@ -794,7 +802,7 @@ fn duplicate_values<'a>( let mut seen = BTreeSet::new(); for value in values { if !seen.insert(value) { - violations.push(format!("중복 {label}: {value}")); + violations.push(format!("duplicate {label}: {value}")); } } } diff --git a/crates/devup-mcp-devup-ui/tests/validation.rs b/crates/devup-mcp-devup-ui/tests/validation.rs index a07227a..e48472b 100644 --- a/crates/devup-mcp-devup-ui/tests/validation.rs +++ b/crates/devup-mcp-devup-ui/tests/validation.rs @@ -6,7 +6,7 @@ fn syntax_accepts_nested_typescript_jsx() { let source = r#" import { Text, VStack } from "@devup-ui/react"; export function Proofread(): JSX.Element { - return 본문; + return Body; } "#; let report = validate_tsx(source).expect("valid TSX"); @@ -16,13 +16,13 @@ fn syntax_accepts_nested_typescript_jsx() { #[test] fn syntax_rejects_invalid_tsx_without_echoing_source_text() { for source in [ - "export function Broken() { return 비밀 본문; }", - "export function Broken() { return 비밀 본문; }", - "export function Broken() { return {비밀 본문 + }; }", + "export function Broken() { return secret body; }", + "export function Broken() { return secret body; }", + "export function Broken() { return {secret body + }; }", ] { let error = validate_tsx(source).expect_err("invalid TSX"); assert_eq!(error.code, ErrorCode::DevupCodegenFailed); - assert!(!error.to_string().contains("비밀 본문")); + assert!(!error.to_string().contains("secret body")); assert!( error.details["errorCount"] .as_u64() diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 5171401..feab71e 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -165,14 +165,14 @@ pub fn validate_asset_requests( requests: &[AssetRequest], ) -> Result<(), DevupError> { if requests.len() > 16 { - return Err(invalid("한 번에 export할 asset은 16개 이하여야 합니다.")); + return Err(invalid("At most 16 assets can be exported at once.")); } let available = discover_asset_manifest(snapshot); let mut seen = std::collections::BTreeSet::new(); for request in requests { if request.scale == 0 || request.scale > 4 || !seen.insert(request.asset_id.as_str()) { return Err(invalid( - "asset 요청의 scale 또는 중복 ID가 올바르지 않습니다.", + "asset request has an invalid scale or a duplicate ID.", )); } let Some(candidate) = available @@ -180,13 +180,13 @@ pub fn validate_asset_requests( .iter() .find(|asset| asset.asset_id == request.asset_id) else { - return Err(invalid("요청한 asset이 snapshot에 없습니다.")); + return Err(invalid("The requested asset is not in the snapshot.")); }; if candidate.node_id != request.node_id || candidate.field != request.field || candidate.image_hash != request.image_hash { - return Err(invalid("asset 요청이 snapshot source와 일치하지 않습니다.")); + return Err(invalid("asset request does not match the snapshot source.")); } } Ok(()) @@ -197,7 +197,7 @@ pub fn resolve_asset_selections( selections: &[AssetSelection], ) -> Result, DevupError> { if selections.len() > 16 { - return Err(invalid("한 번에 export할 asset은 16개 이하여야 합니다.")); + return Err(invalid("At most 16 assets can be exported at once.")); } let manifest = discover_asset_manifest(snapshot); let mut seen = std::collections::BTreeSet::new(); @@ -209,14 +209,14 @@ pub fn resolve_asset_selections( || !seen.insert(selection.asset_id.as_str()) { return Err(invalid( - "asset 선택의 scale 또는 중복 ID가 올바르지 않습니다.", + "asset selection has an invalid scale or a duplicate ID.", )); } let asset = manifest .assets .iter() .find(|asset| asset.asset_id == selection.asset_id) - .ok_or_else(|| invalid("선택한 asset이 snapshot에 없습니다."))?; + .ok_or_else(|| invalid("The selected asset is not in the snapshot."))?; Ok(AssetRequest { asset_id: asset.asset_id.clone(), node_id: asset.node_id.clone(), @@ -238,7 +238,7 @@ pub fn asset_export_from_result( request: &AssetRequest, ) -> Result { let descriptor = find_descriptor(&result.raw) - .ok_or_else(|| invalid("Figma MCP 응답에서 asset descriptor를 찾지 못했습니다."))?; + .ok_or_else(|| invalid("asset descriptor not found in the Figma MCP response."))?; if descriptor.file_key != file_key || descriptor.version.as_deref() != version || descriptor.asset_id != request.asset_id @@ -249,7 +249,7 @@ pub fn asset_export_from_result( || descriptor.scale != request.scale { return Err(invalid( - "asset descriptor가 요청 대상 또는 버전과 다릅니다.", + "asset descriptor target or version does not match the request.", )); } let source_kind = if request.image_hash.is_some() { @@ -276,17 +276,17 @@ pub fn asset_export_from_result( }); } let data = find_binary(&result.raw, request.format.mime_type()) - .ok_or_else(|| invalid("asset export 응답에 요청한 binary가 없습니다."))?; + .ok_or_else(|| invalid("asset export response does not contain the requested binary."))?; let bytes = STANDARD .decode(data.as_bytes()) - .map_err(|_| invalid("asset export binary의 base64가 올바르지 않습니다."))?; + .map_err(|_| invalid("asset export binary base64 is invalid."))?; if bytes.is_empty() || bytes.len() > MAX_ASSET_BYTES || descriptor.byte_length != Some(bytes.len()) || descriptor.sha256.as_deref() != Some(sha256_hex(&bytes).as_str()) { return Err(invalid( - "asset export binary의 길이 또는 hash가 일치하지 않습니다.", + "asset export binary length or hash does not match.", )); } Ok(AssetManifestEntry { diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index 3e0beb7..9782e33 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -291,7 +291,9 @@ impl CollectorSession { pub fn advance(&mut self) -> Result { if self.completed { - return Err(invalid_call("완료된 Figma 수집 session입니다.")); + return Err(invalid_call( + "This Figma collection session is already complete.", + )); } if self.section_index.is_none() && let Some(index) = self.request.cached_section_index.take() @@ -301,10 +303,12 @@ impl CollectorSession { } if self.metadata.is_none() && self.pending.is_empty() && self.queued.is_empty() { if self.request.section.is_some() && self.section_index.is_none() { - let node_id = - self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma Section index에는 node ID가 필요합니다.") - })?; + let node_id = self + .request + .target + .node_id + .clone() + .ok_or_else(|| invalid_call("Figma Section index requires a node ID."))?; self.enqueue( ReadToolCall::section_index(&self.request.target.file_key, &node_id), Some(node_id), @@ -323,7 +327,7 @@ impl CollectorSession { } if let Some(options) = self.request.explore.clone() { let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma 주변 화면 탐색에는 node ID가 필요합니다.") + invalid_call("Figma nearby-screen exploration requires a node ID.") })?; self.enqueue( ReadToolCall::explore_snapshot( @@ -345,10 +349,12 @@ impl CollectorSession { return self.advance(); } if self.fast_path_eligible() && !self.fast_attempted { - let node_id = - self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma fast snapshot에는 node ID가 필요합니다.") - })?; + let node_id = self + .request + .target + .node_id + .clone() + .ok_or_else(|| invalid_call("Figma fast snapshot requires a node ID."))?; self.fast_attempted = true; self.enqueue( ReadToolCall::fast_snapshot(&self.request.target.file_key, &node_id), @@ -422,7 +428,7 @@ impl CollectorSession { && self.queued.is_empty() { let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma reference PNG 수집에는 node ID가 필요합니다.") + invalid_call("Figma reference PNG collection requires a node ID.") })?; self.reference_png_scheduled = true; self.enqueue( @@ -438,10 +444,9 @@ impl CollectorSession { && self.variables.is_none() && self.queued.is_empty() { - let node_id = self - .root_node_id - .clone() - .ok_or_else(|| invalid_call("Figma 변수 수집에 사용할 root node ID가 없습니다."))?; + let node_id = self.root_node_id.clone().ok_or_else(|| { + invalid_call("No root node ID available for Figma variable collection.") + })?; self.enqueue( ReadToolCall::snapshot( &self.request.target.file_key, @@ -462,7 +467,7 @@ impl CollectorSession { let catalog = self .variable_catalog .take() - .ok_or_else(|| invalid_call("Figma 변수 catalog가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma variable catalog is missing."))?; self.variables = Some(merge_variable_results( catalog, std::mem::take(&mut self.variable_batches).into_values(), @@ -488,7 +493,7 @@ impl CollectorSession { let refs = self .used_resource_refs .take() - .ok_or_else(|| invalid_call("사용된 Figma 리소스 참조가 없습니다."))?; + .ok_or_else(|| invalid_call("Used Figma resource references are missing."))?; let merged = merge_used_resource_results( &refs, std::mem::take(&mut self.variable_batches).into_values(), @@ -504,7 +509,7 @@ impl CollectorSession { &mut combined, result .take() - .ok_or_else(|| invalid_call("fallback resource 결과가 없습니다."))?, + .ok_or_else(|| invalid_call("fallback resource result is missing."))?, )?; result = combined; } @@ -550,7 +555,7 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("알 수 없거나 이미 처리한 Figma call ID입니다."))?; + .ok_or_else(|| invalid_call("Unknown or already-handled Figma call ID."))?; self.consumed.insert(call_id.to_owned()); match pending.kind { CallKind::FastSnapshot => { @@ -587,20 +592,18 @@ impl CollectorSession { pub fn reject(&mut self, call_id: &str, error: &DevupError) -> Result { let Some(pending) = self.pending.get(call_id) else { - return Err(invalid_call( - "알 수 없거나 이미 처리한 Figma call ID입니다.", - )); + return Err(invalid_call("Unknown or already-handled Figma call ID.")); }; if pending.kind == CallKind::FastSnapshot && is_section_target_error(error) { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("fast Section probe call이 없습니다."))?; + .ok_or_else(|| invalid_call("fast Section probe call is missing."))?; self.consumed.insert(call_id.to_owned()); let node_id = pending .planned .expected_node_id - .ok_or_else(|| invalid_call("fast Section probe의 node ID가 없습니다."))?; + .ok_or_else(|| invalid_call("fast Section probe node ID is missing."))?; self.request.section = Some(SectionReadOptions { frame_ids: Vec::new(), all_screens: false, @@ -616,10 +619,10 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("asset call이 없습니다."))?; + .ok_or_else(|| invalid_call("asset call is missing."))?; self.consumed.insert(call_id.to_owned()); let ReadToolCall::AssetExport { request, .. } = pending.planned.call else { - return Err(invalid_call("asset call 형식이 올바르지 않습니다.")); + return Err(invalid_call("asset call format is invalid.")); }; self.record_asset_failure(*request, "DEVUP_ASSET_EXPORT_FAILED"); return Ok(true); @@ -628,10 +631,10 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("large value call이 없습니다."))?; + .ok_or_else(|| invalid_call("large value call is missing."))?; self.consumed.insert(call_id.to_owned()); let ReadToolCall::LargeValue { options, .. } = pending.planned.call else { - return Err(invalid_call("large value call 형식이 올바르지 않습니다.")); + return Err(invalid_call("large value call format is invalid.")); }; self.record_large_value_unsupported(&options, "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM")?; return Ok(true); @@ -646,12 +649,12 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("Section legacy call이 없습니다."))?; + .ok_or_else(|| invalid_call("Section legacy call is missing."))?; self.consumed.insert(call_id.to_owned()); let node_id = pending .planned .expected_node_id - .ok_or_else(|| invalid_call("Section legacy call의 node ID가 없습니다."))?; + .ok_or_else(|| invalid_call("Section legacy call node ID is missing."))?; self.section_fallback_roots.remove(&node_id); self.screen_failures.push(ScreenFailure { node_id, @@ -673,7 +676,7 @@ impl CollectorSession { let pending = self .pending .remove(call_id) - .ok_or_else(|| invalid_call("fast call이 없습니다."))?; + .ok_or_else(|| invalid_call("fast call is missing."))?; self.consumed.insert(call_id.to_owned()); if pending.kind == CallKind::FastMultiRoot { self.fallback_multi_root_batch(&pending.planned, fallback_category(error))?; @@ -765,7 +768,7 @@ impl CollectorSession { .target .node_id .clone() - .ok_or_else(|| invalid_call("Figma fast snapshot에는 node ID가 필요합니다."))?; + .ok_or_else(|| invalid_call("Figma fast snapshot requires a node ID."))?; self.root_node_id = Some(root_id.clone()); self.source_version = payload.snapshot.version.clone(); self.metadata_root_ids = payload.snapshot.root_ids.clone(); @@ -887,13 +890,13 @@ impl CollectorSession { let catalog = snapshot_chunk_from_result(&result)?; if catalog.file_key != planned.expected_file_key { return Err(invalid_call( - "Figma page catalog의 file key가 요청과 다릅니다.", + "Figma page catalog file key does not match the request.", )); } if catalog.root_ids.is_empty() { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma page catalog가 비어 있습니다.", + "Figma page catalog is empty.", false, )); } @@ -901,7 +904,7 @@ impl CollectorSession { .request .search .clone() - .ok_or_else(|| invalid_call("검색 설정 없이 page catalog를 수집했습니다."))?; + .ok_or_else(|| invalid_call("Collected a page catalog without search options."))?; self.metadata = Some(result.raw); self.root_node_id = catalog.root_ids.first().cloned(); self.source_version = catalog.version.clone(); @@ -932,17 +935,16 @@ impl CollectorSession { let chunk = snapshot_chunk_from_result(&result)?; if chunk.file_key != planned.expected_file_key { return Err(invalid_call( - "Figma 탐색 projection의 file key가 요청과 다릅니다.", + "Figma exploration projection file key does not match the request.", )); } - let expected_node_id = planned - .expected_node_id - .as_deref() - .ok_or_else(|| invalid_call("Figma 탐색 projection의 expected node ID가 없습니다."))?; + let expected_node_id = planned.expected_node_id.as_deref().ok_or_else(|| { + invalid_call("Figma exploration projection expected node ID is missing.") + })?; if !chunk.nodes.iter().any(|node| node.id == expected_node_id) { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 탐색 projection에서 anchor node를 찾지 못했습니다.", + "anchor node not found in the Figma exploration projection.", false, )); } @@ -963,18 +965,18 @@ impl CollectorSession { let chunk = snapshot_chunk_from_result(&result)?; if chunk.file_key != planned.expected_file_key { return Err(invalid_call( - "Figma Section index의 file key가 요청과 다릅니다.", + "Figma Section index file key does not match the request.", )); } let section_id = planned .expected_node_id .as_deref() - .ok_or_else(|| invalid_call("Figma Section index의 node ID가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma Section index node ID is missing."))?; if chunk.root_ids.as_slice() != [section_id] || !chunk.nodes.iter().any(|node| node.id == section_id) { return Err(invalid_call( - "Figma Section index가 요청한 Section과 일치하지 않습니다.", + "Figma Section index does not match the requested Section.", )); } let snapshot = merge_chunks(vec![chunk.clone()])?; @@ -983,7 +985,7 @@ impl CollectorSession { .request .section .clone() - .ok_or_else(|| invalid_call("Section read options가 없습니다."))?; + .ok_or_else(|| invalid_call("Section read options are missing."))?; self.source_version = index.source_version.clone(); self.root_node_id = Some(section_id.to_owned()); self.metadata_root_ids = chunk.root_ids.clone(); @@ -1039,14 +1041,14 @@ impl CollectorSession { || self.request.target.node_id.as_deref() != Some(index.section.node_id.as_str()) { return Err(invalid_call( - "cached Section index가 요청한 Section과 일치하지 않습니다.", + "cached Section index does not match the requested Section.", )); } let options = self .request .section .clone() - .ok_or_else(|| invalid_call("cached Section index에 선택 설정이 없습니다."))?; + .ok_or_else(|| invalid_call("cached Section index has no selection options."))?; let selected = index.select(&options.frame_ids, options.all_screens)?; let batches = plan_batches(&index, &selected, BatchLimits::default())?; self.source_version = index.source_version.clone(); @@ -1066,7 +1068,7 @@ impl CollectorSession { .target .node_id .clone() - .ok_or_else(|| invalid_call("cached Section index의 Section ID가 없습니다."))?; + .ok_or_else(|| invalid_call("cached Section index Section ID is missing."))?; for batch in batches { if batch.oversized { self.mark_section_legacy("oversized-section-root".to_owned()); @@ -1102,9 +1104,7 @@ impl CollectorSession { .. } = &planned.call else { - return Err(invalid_call( - "multi-root snapshot call 형식이 올바르지 않습니다.", - )); + return Err(invalid_call("multi-root snapshot call format is invalid.")); }; let payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids) { Ok(payload) => payload, @@ -1119,7 +1119,7 @@ impl CollectorSession { { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "multi-root 수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during multi-root collection.", true, )); } @@ -1158,14 +1158,10 @@ impl CollectorSession { .. } = &planned.call else { - return Err(invalid_call( - "multi-root fallback call 형식이 올바르지 않습니다.", - )); + return Err(invalid_call("multi-root fallback call format is invalid.")); }; if root_ids.is_empty() { - return Err(invalid_call( - "multi-root fallback에 선택된 root가 없습니다.", - )); + return Err(invalid_call("multi-root fallback has no selected roots.")); } self.mark_section_legacy(reason); for root_id in root_ids { @@ -1220,7 +1216,7 @@ impl CollectorSession { .any(|root_id| !observed.contains(root_id) && !failed.contains(root_id.as_str())) { return Err(invalid_call( - "Section snapshot에 선택된 root가 모두 포함되지 않았습니다.", + "Section snapshot does not include all selected roots.", )); } Ok(vec![SnapshotChunk { @@ -1249,7 +1245,7 @@ impl CollectorSession { let key = (descriptor.node_id.clone(), descriptor.field.clone()); if self.large_values.contains_key(&key) { return Err(invalid_call( - "동일한 Figma large value descriptor가 중복되었습니다.", + "Duplicate Figma large value descriptor for the same field.", )); } let options = LargeValueReadOptions::from_descriptor( @@ -1280,7 +1276,7 @@ impl CollectorSession { result: UpstreamResult, ) -> Result<(), DevupError> { let ReadToolCall::LargeValue { options, .. } = &planned.call else { - return Err(invalid_call("large value call 형식이 올바르지 않습니다.")); + return Err(invalid_call("large value call format is invalid.")); }; let result = large_value_from_result(&result)?; if let LargeValueResult::Unsupported(unsupported) = result { @@ -1293,7 +1289,7 @@ impl CollectorSession { || unsupported.error_code != "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM" { return Err(invalid_call( - "large value unsupported 응답이 요청과 일치하지 않습니다.", + "large value unsupported response does not match the request.", )); } return self.record_large_value_unsupported(options, &unsupported.error_code); @@ -1303,7 +1299,7 @@ impl CollectorSession { }; if fragment.offset != options.offset { return Err(invalid_call( - "large value fragment offset이 요청과 일치하지 않습니다.", + "large value fragment offset does not match the request.", )); } let key = (options.node_id.clone(), options.field.clone()); @@ -1312,19 +1308,19 @@ impl CollectorSession { let assembler = self .large_values .get_mut(&key) - .ok_or_else(|| invalid_call("large value assembler가 없습니다."))?; + .ok_or_else(|| invalid_call("large value assembler is missing."))?; assembler.push(fragment)?; if complete { let assembler = self .large_values .remove(&key) - .ok_or_else(|| invalid_call("large value assembler가 없습니다."))?; + .ok_or_else(|| invalid_call("large value assembler is missing."))?; let descriptor = assembler.descriptor().clone(); let value = assembler.finish()?; replace_descriptor(&mut self.snapshot_chunks, &descriptor, value)?; } else { if next_offset <= options.offset { - return Err(invalid_call("large value cursor가 진행되지 않았습니다.")); + return Err(invalid_call("large value cursor did not advance.")); } let descriptor = assembler.descriptor().clone(); let next = LargeValueReadOptions::from_descriptor( @@ -1350,7 +1346,7 @@ impl CollectorSession { let assembler = self .large_values .remove(&key) - .ok_or_else(|| invalid_call("large value assembler가 없습니다."))?; + .ok_or_else(|| invalid_call("large value assembler is missing."))?; let descriptor = assembler.descriptor().clone(); replace_descriptor( &mut self.snapshot_chunks, @@ -1374,7 +1370,7 @@ impl CollectorSession { chunk.diagnostics.push(crate::Diagnostic { code: error_code.to_owned(), message: - "Figma upstream에서 큰 필드를 다시 읽을 수 없어 명시적 marker를 유지했습니다." + "Figma upstream could not re-read the large field, so an explicit marker was kept." .to_owned(), node_id: Some(descriptor.node_id), severity: Some(crate::DiagnosticSeverity::Warning), @@ -1396,7 +1392,7 @@ impl CollectorSession { version, request, .. } = &planned.call else { - return Err(invalid_call("asset call 형식이 올바르지 않습니다.")); + return Err(invalid_call("asset call format is invalid.")); }; let exported = asset_export_from_result( &result, @@ -1421,29 +1417,29 @@ impl CollectorSession { result: UpstreamResult, ) -> Result<(), DevupError> { let ReadToolCall::Screenshot { file_key, node_id } = &planned.call else { - return Err(invalid_call("reference PNG call 형식이 올바르지 않습니다.")); + return Err(invalid_call("reference PNG call format is invalid.")); }; if file_key != &planned.expected_file_key || planned.expected_node_id.as_deref() != Some(node_id.as_str()) { return Err(invalid_call( - "reference PNG call의 Figma 대상이 요청과 다릅니다.", + "reference PNG call Figma target does not match the request.", )); } let data_base64 = take_single_png_data(result.raw)?; if data_base64.len() > MAX_REFERENCE_PNG_BASE64_BYTES { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG가 허용 크기를 초과했습니다.", + "Figma reference PNG exceeds the allowed size.", false, )); } let bytes = STANDARD .decode(data_base64.as_bytes()) - .map_err(|_| invalid_call("Figma reference PNG의 base64가 올바르지 않습니다."))?; + .map_err(|_| invalid_call("Figma reference PNG base64 is invalid."))?; if bytes.is_empty() || bytes.len() > MAX_REFERENCE_PNG_BYTES { return Err(invalid_call( - "Figma reference PNG의 형식 또는 크기가 올바르지 않습니다.", + "Figma reference PNG format or size is invalid.", )); } validate_reference_png(&bytes)?; @@ -1491,7 +1487,7 @@ impl CollectorSession { { chunk.diagnostics.push(crate::Diagnostic { code: error_code.to_owned(), - message: "요청한 Figma asset을 export하지 못해 layout 출력은 유지했습니다." + message: "Failed to export the requested Figma asset; layout output was kept." .to_owned(), node_id: Some(request.node_id.clone()), severity: Some(crate::DiagnosticSeverity::Warning), @@ -1518,7 +1514,7 @@ impl CollectorSession { if let MetadataResult::TopLevelPages(pages) = metadata { if planned.expected_node_id.is_some() { return Err(invalid_call( - "page metadata 요청에 top-level page 목록이 반환되었습니다.", + "A page metadata request returned the top-level page list.", )); } self.record_metadata(result.raw); @@ -1559,14 +1555,16 @@ impl CollectorSession { unreachable!("top-level page metadata is handled above") }; if document.file_key != self.request.target.file_key { - return Err(invalid_call("Figma metadata의 file key가 요청과 다릅니다.")); + return Err(invalid_call( + "Figma metadata file key does not match the request.", + )); } if let (Some(existing), Some(incoming)) = (&self.source_version, &document.version) && existing != incoming { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "metadata 수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during metadata collection.", true, )); } @@ -1586,7 +1584,7 @@ impl CollectorSession { let root = document.root().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma metadata에서 대상 node를 찾지 못했습니다.", + "Target node not found in the Figma metadata.", false, ) })?; @@ -1676,12 +1674,14 @@ impl CollectorSession { ) -> Result<(), DevupError> { let mut chunk = snapshot_chunk_from_result(&result)?; if chunk.file_key != planned.expected_file_key { - return Err(invalid_call("Figma snapshot의 file key가 요청과 다릅니다.")); + return Err(invalid_call( + "Figma snapshot file key does not match the request.", + )); } if chunk.version != self.source_version { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during collection.", true, )); } @@ -1699,23 +1699,23 @@ impl CollectorSession { let expected_next = options .offset .checked_add(chunk.nodes.len()) - .ok_or_else(|| invalid_call("Figma snapshot cursor offset이 넘쳤습니다."))?; + .ok_or_else(|| invalid_call("Figma snapshot cursor offset overflowed."))?; if cursor.next_offset != expected_next || cursor.next_offset > cursor.total_nodes { return Err(invalid_call( - "Figma snapshot cursor가 수집한 node 범위와 일치하지 않습니다.", + "Figma snapshot cursor does not match the collected node range.", )); } if cursor.complete != (cursor.next_offset >= cursor.total_nodes) { return Err(invalid_call( - "Figma snapshot cursor의 완료 상태가 node 수와 일치하지 않습니다.", + "Figma snapshot cursor completion state does not match the node count.", )); } if !cursor.complete { if chunk.nodes.is_empty() { - return Err(invalid_call("Figma snapshot cursor가 진행되지 않았습니다.")); + return Err(invalid_call("Figma snapshot cursor did not advance.")); } let node_id = planned.expected_node_id.clone().ok_or_else(|| { - invalid_call("Figma snapshot cursor의 root node ID가 없습니다.") + invalid_call("Figma snapshot cursor root node ID is missing.") })?; self.enqueue( ReadToolCall::snapshot_chunk( @@ -1737,10 +1737,9 @@ impl CollectorSession { fn accept_variable_catalog(&mut self, result: UpstreamResult) -> Result<(), DevupError> { let catalog = catalog_from_result(&result)?; - let node_id = self - .root_node_id - .clone() - .ok_or_else(|| invalid_call("Figma 변수 batch에 사용할 root node ID가 없습니다."))?; + let node_id = self.root_node_id.clone().ok_or_else(|| { + invalid_call("No root node ID available for the Figma variable batch.") + })?; for variable_ids in catalog.variable_ids.chunks(VARIABLE_BATCH_SIZE) { self.enqueue( ReadToolCall::resource_batch( @@ -1790,7 +1789,7 @@ impl CollectorSession { .collect::>(); let refs = collect_used_resource_refs(&chunks); let node_id = self.root_node_id.clone().ok_or_else(|| { - invalid_call("사용된 Figma 리소스 batch에 사용할 root node ID가 없습니다.") + invalid_call("No root node ID available for the used Figma resource batch.") })?; for batch in used_resource_batches(&refs)? { self.enqueue( @@ -1819,7 +1818,7 @@ impl CollectorSession { let diagnostic = crate::Diagnostic { code: "DEVUP_RESOURCE_UNRESOLVED".to_owned(), message: format!( - "Figma 리소스를 확인할 수 없어 raw 값으로 대체했습니다: field={}, resourceId={}", + "Could not resolve the Figma resource; substituted the raw value: field={}, resourceId={}", occurrence.field, occurrence.resource_id ), node_id: Some(occurrence.node_id.clone()), @@ -1852,11 +1851,11 @@ impl CollectorSession { batch: &VariableBatchResult, ) -> Result<(), DevupError> { let node_id = self.root_node_id.clone().ok_or_else(|| { - invalid_call("Figma style consumer 수집에 사용할 root node ID가 없습니다.") + invalid_call("No root node ID available for Figma style consumer collection.") })?; for style in &batch.styles { let Some(object) = style.as_object() else { - return Err(invalid_call("Figma style batch 형식이 올바르지 않습니다.")); + return Err(invalid_call("Figma style batch format is invalid.")); }; let Some(consumer_count) = object.get("$consumerCount").and_then(Value::as_u64) else { continue; @@ -1864,11 +1863,11 @@ impl CollectorSession { let id = object .get("id") .and_then(Value::as_str) - .ok_or_else(|| invalid_call("Figma style ID가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma style ID is missing."))?; let style_type = object .get("styleType") .and_then(Value::as_str) - .ok_or_else(|| invalid_call("Figma style type이 없습니다."))?; + .ok_or_else(|| invalid_call("Figma style type is missing."))?; for start in (0..consumer_count as usize).step_by(STYLE_CONSUMER_BATCH_SIZE) { let end = (start + STYLE_CONSUMER_BATCH_SIZE).min(consumer_count as usize); self.enqueue( @@ -1912,25 +1911,25 @@ impl CollectorSession { fn take_single_png_data(value: Value) -> Result { let result = serde_json::from_value::(value) - .map_err(|_| invalid_call("Figma screenshot 응답 형식이 올바르지 않습니다."))?; + .map_err(|_| invalid_call("Figma screenshot response format is invalid."))?; if result.is_error == Some(true) || result.content.len() != 1 { return Err(invalid_call( - "Figma screenshot 응답에는 image/png content가 정확히 하나 있어야 합니다.", + "Figma screenshot response must contain exactly one image/png content block.", )); } let content = result .content .into_iter() .next() - .ok_or_else(|| invalid_call("Figma screenshot 응답에 image/png content가 없습니다."))?; + .ok_or_else(|| invalid_call("Figma screenshot response has no image/png content."))?; let ContentBlock::Image(image) = content else { return Err(invalid_call( - "Figma screenshot 응답에는 image/png content가 정확히 하나 있어야 합니다.", + "Figma screenshot response must contain exactly one image/png content block.", )); }; if image.mime_type != "image/png" { return Err(invalid_call( - "Figma screenshot 응답의 MIME 형식이 image/png가 아닙니다.", + "Figma screenshot response MIME type is not image/png.", )); } Ok(image.data) @@ -1947,7 +1946,7 @@ fn validate_reference_png(bytes: &[u8]) -> Result<(), DevupError> { let decoded_bytes = usize::try_from(decoder.total_bytes()).map_err(|_| { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG의 decoded 크기가 허용 범위를 초과했습니다.", + "Figma reference PNG decoded size exceeds the allowed range.", false, ) })?; @@ -1959,7 +1958,7 @@ fn validate_reference_png(bytes: &[u8]) -> Result<(), DevupError> { { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG의 dimensions 또는 decoded 크기가 허용 범위를 초과했습니다.", + "Figma reference PNG dimensions or decoded size exceed the allowed range.", false, )); } @@ -1973,11 +1972,11 @@ fn reference_png_decode_error(error: ImageError) -> DevupError { if matches!(error, ImageError::Limits(_)) { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG의 dimensions 또는 decoded 크기가 허용 범위를 초과했습니다.", + "Figma reference PNG dimensions or decoded size exceed the allowed range.", false, ) } else { - invalid_call("Figma reference PNG 데이터가 손상되었습니다.") + invalid_call("Figma reference PNG data is corrupted.") } } @@ -2057,11 +2056,11 @@ fn merge_fast_resources( let current = current .raw .as_object_mut() - .ok_or_else(|| invalid_call("기존 multi-root resource 형식이 올바르지 않습니다."))?; + .ok_or_else(|| invalid_call("Existing multi-root resource format is invalid."))?; let incoming = incoming .raw .as_object() - .ok_or_else(|| invalid_call("multi-root resource 형식이 올바르지 않습니다."))?; + .ok_or_else(|| invalid_call("multi-root resource format is invalid."))?; for field in ["collections", "variables", "styles", "usedRemoteVariables"] { let mut values = BTreeMap::::new(); for value in current @@ -2080,13 +2079,13 @@ fn merge_fast_resources( let id = value .get("id") .and_then(Value::as_str) - .ok_or_else(|| invalid_call("multi-root resource ID가 없습니다."))?; + .ok_or_else(|| invalid_call("multi-root resource ID is missing."))?; if let Some(previous) = values.get(id) && previous != value { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "multi-root resource 내용이 batch 사이에서 달라졌습니다.", + "multi-root resource contents differ between batches.", true, )); } @@ -2114,7 +2113,7 @@ fn merge_fast_resources( value .as_str() .map(str::to_owned) - .ok_or_else(|| invalid_call("multi-root resource ID 형식이 올바르지 않습니다.")) + .ok_or_else(|| invalid_call("multi-root resource ID format is invalid.")) }) .collect::, _>>()?; current.insert( @@ -2136,7 +2135,7 @@ fn merge_fast_resources( ) .map(|value| serde_json::to_string(value).map(|key| (key, value.clone()))) .collect::, _>>() - .map_err(|_| invalid_call("multi-root unresolved resource를 직렬화할 수 없습니다."))?; + .map_err(|_| invalid_call("Could not serialize the multi-root unresolved resource."))?; current.insert( "unresolved".to_owned(), Value::Array(unresolved.into_values().collect()), @@ -2181,7 +2180,7 @@ fn used_resource_batches(refs: &UsedResourceRefs) -> Result, if current.variable_ids.is_empty() && current.styles.is_empty() { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "단일 Figma 리소스 ID가 안전한 batch 크기를 초과했습니다.", + "A single Figma resource ID exceeds the safe batch size.", false, )); } @@ -2194,7 +2193,7 @@ fn used_resource_batches(refs: &UsedResourceRefs) -> Result, if !used_resource_batch_fits(¤t) { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "단일 Figma 리소스 ID가 안전한 batch 크기를 초과했습니다.", + "A single Figma resource ID exceeds the safe batch size.", false, )); } diff --git a/crates/devup-mcp-figma/src/explore.rs b/crates/devup-mcp-figma/src/explore.rs index 4ebb136..7b8b1f5 100644 --- a/crates/devup-mcp-figma/src/explore.rs +++ b/crates/devup-mcp-figma/src/explore.rs @@ -108,7 +108,7 @@ impl TryFrom<&RawNode> for ExploreNode { .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Figma 탐색 projection에 유효한 node bounds가 없습니다.", + "Figma exploration projection has no valid node bounds.", false, ) })?; @@ -260,21 +260,21 @@ pub fn explore_snapshot( if options.limit == 0 || options.limit > 100 { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "탐색 limit은 1 이상 100 이하여야 합니다.", + "Exploration limit must be between 1 and 100.", false, )); } let anchor_id = target.node_id.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 주변 화면 탐색에는 node-id가 필요합니다.", + "Figma nearby-screen exploration requires a node-id.", false, ) })?; let raw_anchor = snapshot.nodes.get(anchor_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 탐색 projection에서 anchor node를 찾지 못했습니다.", + "anchor node not found in the Figma exploration projection.", false, ) })?; @@ -463,14 +463,14 @@ pub fn collect_section_notes(snapshot: &Snapshot, section_id: &str) -> Result self.descriptor.cursor.max_chunk_bytes || bytes.len() > MAX_LARGE_VALUE_CHUNK_BYTES @@ -145,14 +145,12 @@ impl LargeValueAssembler { || fragment.next_offset > self.descriptor.byte_length || fragment.complete != (fragment.next_offset == self.descriptor.byte_length) { - return Err(invalid( - "large value fragment의 byte 범위가 올바르지 않습니다.", - )); + return Err(invalid("large value fragment byte range is invalid.")); } if let Some(existing) = self.fragments.get(&fragment.offset) { if existing != &bytes { return Err(invalid( - "large value fragment가 같은 offset에서 충돌합니다.", + "large value fragments conflict at the same offset.", )); } return Ok(()); @@ -164,13 +162,15 @@ impl LargeValueAssembler { pub fn finish(self) -> Result { if !self.saw_complete { - return Err(invalid("large value fragment의 마지막 범위가 없습니다.")); + return Err(invalid( + "large value fragment for the final range is missing.", + )); } let mut output = Vec::with_capacity(self.descriptor.byte_length); for (offset, bytes) in self.fragments { if offset != output.len() { return Err(invalid( - "large value fragment 범위가 누락되었거나 겹칩니다.", + "large value fragment ranges are missing or overlapping.", )); } output.extend_from_slice(&bytes); @@ -179,11 +179,11 @@ impl LargeValueAssembler { || sha256_hex(&output) != self.descriptor.sha256 { return Err(invalid( - "large value fragment의 길이 또는 hash가 일치하지 않습니다.", + "large value fragment length or hash does not match.", )); } serde_json::from_slice(&output) - .map_err(|_| invalid("large value fragment를 JSON 값으로 복원할 수 없습니다.")) + .map_err(|_| invalid("large value fragment cannot be restored as a JSON value.")) } } @@ -196,14 +196,12 @@ pub(crate) fn descriptors_in_chunk( let Some(raw) = value.get("$largeValue") else { continue; }; - let descriptor: LargeValueDescriptor = - serde_json::from_value(raw.clone()).map_err(|_| { - invalid("snapshot의 large value descriptor 형식이 올바르지 않습니다.") - })?; + let descriptor: LargeValueDescriptor = serde_json::from_value(raw.clone()) + .map_err(|_| invalid("snapshot large value descriptor format is invalid."))?; validate_descriptor(&descriptor)?; if descriptor.node_id != node.id || descriptor.field != *field { return Err(invalid( - "snapshot의 large value descriptor 대상이 필드와 다릅니다.", + "snapshot large value descriptor target does not match its field.", )); } descriptors.push(descriptor); @@ -222,7 +220,7 @@ pub(crate) fn large_value_from_result( result: &UpstreamResult, ) -> Result { find_large_value_result(&result.raw) - .ok_or_else(|| invalid("Figma MCP 응답에서 large value fragment를 찾지 못했습니다.")) + .ok_or_else(|| invalid("large value fragment not found in the Figma MCP response.")) } pub(crate) fn replace_descriptor( @@ -240,22 +238,24 @@ pub(crate) fn replace_descriptor( .fields .get_mut(&descriptor.field) .or_else(|| node.extra.get_mut(&descriptor.field)) - .ok_or_else(|| invalid("large value descriptor가 가리키는 필드가 없습니다."))?; + .ok_or_else(|| { + invalid("field referenced by the large value descriptor is missing.") + })?; let observed: LargeValueDescriptor = serde_json::from_value( slot.get("$largeValue") .cloned() - .ok_or_else(|| invalid("large value descriptor marker가 없습니다."))?, + .ok_or_else(|| invalid("large value descriptor marker is missing."))?, ) - .map_err(|_| invalid("large value descriptor marker가 올바르지 않습니다."))?; + .map_err(|_| invalid("large value descriptor marker is invalid."))?; if observed != *descriptor { - return Err(invalid("large value descriptor가 수집 중 변경되었습니다.")); + return Err(invalid("large value descriptor changed during collection.")); } *slot = value; node.field_errors.remove(&descriptor.field); return Ok(()); } } - Err(invalid("large value descriptor의 node를 찾지 못했습니다.")) + Err(invalid("node for the large value descriptor not found.")) } fn validate_descriptor(descriptor: &LargeValueDescriptor) -> Result<(), DevupError> { @@ -272,9 +272,7 @@ fn validate_descriptor(descriptor: &LargeValueDescriptor) -> Result<(), DevupErr || descriptor.cursor.max_chunk_bytes == 0 || descriptor.cursor.max_chunk_bytes > MAX_LARGE_VALUE_CHUNK_BYTES { - return Err(invalid( - "large value descriptor의 범위 또는 hash가 올바르지 않습니다.", - )); + return Err(invalid("large value descriptor range or hash is invalid.")); } Ok(()) } diff --git a/crates/devup-mcp-figma/src/payload.rs b/crates/devup-mcp-figma/src/payload.rs index 7fb87a2..85208d6 100644 --- a/crates/devup-mcp-figma/src/payload.rs +++ b/crates/devup-mcp-figma/src/payload.rs @@ -149,7 +149,7 @@ pub fn validate_payload_context( { return Err(DevupError::new( crate::ErrorCode::DevupFigmaHandoffInvalid, - "Figma payload가 요청한 파일 또는 node와 일치하지 않습니다.", + "Figma payload does not match the requested file or node.", false, )); } diff --git a/crates/devup-mcp-figma/src/search.rs b/crates/devup-mcp-figma/src/search.rs index 07d6e49..800365b 100644 --- a/crates/devup-mcp-figma/src/search.rs +++ b/crates/devup-mcp-figma/src/search.rs @@ -41,14 +41,14 @@ pub fn search_snapshot( if query.is_empty() { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "검색 query는 비어 있을 수 없습니다.", + "Search query cannot be empty.", false, )); } if options.limit == 0 || options.limit > 100 { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "검색 limit은 1 이상 100 이하여야 합니다.", + "Search limit must be between 1 and 100.", false, )); } @@ -58,7 +58,7 @@ pub fn search_snapshot( ) { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "match는 exact, normalized 또는 fuzzy여야 합니다.", + "match must be exact, normalized, or fuzzy.", false, )); } diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index e321181..7158a12 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -49,18 +49,18 @@ impl SectionIndex { ) -> Result, DevupError> { if all_screens && !frame_ids.is_empty() { return Err(invalid_selection( - "frameIds와 allScreens는 동시에 사용할 수 없습니다.", + "frameIds and allScreens cannot be used together.", )); } if !all_screens && frame_ids.is_empty() { return Err(invalid_selection( - "Section root 수집에는 frameIds 또는 allScreens가 필요합니다.", + "Section root collection requires frameIds or allScreens.", )); } if self.truncated && all_screens { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "잘린 Section index에서는 allScreens를 사용할 수 없습니다.", + "allScreens cannot be used with a truncated Section index.", false, )); } @@ -69,7 +69,7 @@ impl SectionIndex { .map(String::as_str) .collect::>(); if requested.len() != frame_ids.len() { - return Err(invalid_selection("frameIds에 중복 node가 있습니다.")); + return Err(invalid_selection("frameIds contains duplicate nodes.")); } let candidates = self .candidates @@ -79,7 +79,7 @@ impl SectionIndex { if let Some(foreign) = requested.difference(&candidates).next() { return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - format!("Section 내부 screen frame이 아니거나 존재하지 않습니다: {foreign}"), + format!("Not a screen frame inside the Section, or it does not exist: {foreign}"), false, )); } @@ -122,27 +122,25 @@ pub fn build_section_index( ) -> Result { if snapshot.file_key != target.file_key { return Err(invalid_selection( - "Section index의 file key가 요청과 다릅니다.", + "Section index file key does not match the request.", )); } let section_id = target.node_id.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Section index에는 node-id가 필요합니다.", + "Section index requires a node-id.", false, ) })?; let section = snapshot.nodes.get(section_id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Section index에서 대상 node를 찾지 못했습니다.", + "Target node not found for the Section index.", false, ) })?; if section.node_type != "SECTION" { - return Err(invalid_selection( - "Section index 대상은 SECTION이어야 합니다.", - )); + return Err(invalid_selection("Section index target must be a SECTION.")); } let section_node = ExploreNode::try_from(section)?; let mut screen_nodes = Vec::new(); @@ -247,7 +245,9 @@ pub fn plan_batches( limits: BatchLimits, ) -> Result, DevupError> { if limits.max_estimated_bytes == 0 || limits.max_nodes == 0 { - return Err(invalid_selection("Section batch 상한은 0보다 커야 합니다.")); + return Err(invalid_selection( + "Section batch limits must be greater than 0.", + )); } let selected = index.select(selected_root_ids, false)?; let by_id = index @@ -266,7 +266,7 @@ pub fn plan_batches( let candidate = by_id .get(root_id.as_str()) .copied() - .ok_or_else(|| invalid_selection("Section batch candidate가 없습니다."))?; + .ok_or_else(|| invalid_selection("Section batch candidate is missing."))?; let rank = visual_rank[&root_id]; Ok((rank, root_id, candidate)) }) diff --git a/crates/devup-mcp-figma/src/snapshot.rs b/crates/devup-mcp-figma/src/snapshot.rs index a5dfdfe..20b7419 100644 --- a/crates/devup-mcp-figma/src/snapshot.rs +++ b/crates/devup-mcp-figma/src/snapshot.rs @@ -111,8 +111,8 @@ pub enum SnapshotCursorError { impl SnapshotCursorError { pub fn korean_message(self) -> &'static str { match self { - Self::Duplicated => "Figma snapshot 응답에 cursor가 중복되었습니다.", - Self::Shape => "Figma snapshot cursor 형식이 올바르지 않습니다.", + Self::Duplicated => "Figma snapshot response contains duplicate cursors.", + Self::Shape => "Figma snapshot cursor format is invalid.", } } @@ -416,7 +416,7 @@ pub fn merge_chunks(chunks: Vec) -> Result let first = chunks.first().ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "병합할 Figma snapshot이 없습니다.", + "No Figma snapshot to merge.", false, ) })?; @@ -431,7 +431,7 @@ pub fn merge_chunks(chunks: Vec) -> Result if chunk.file_key != file_key || chunk.version != version { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma 파일 버전이 변경되었습니다. 다시 시도하세요.", + "The Figma file version changed during collection. Try again.", true, )); } @@ -445,7 +445,7 @@ pub fn merge_chunks(chunks: Vec) -> Result if existing != &node { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "동일한 Figma node에 서로 다른 snapshot 데이터가 반환되었습니다.", + "Different snapshot data was returned for the same Figma node.", true, )); } @@ -469,7 +469,7 @@ pub fn snapshot_chunk_from_result(result: &UpstreamResult) -> Result ( ErrorCode::DevupFigmaCatalogRejected, - "이 client는 Figma MCP Catalog에서 승인되지 않았습니다.", + "This client is not approved in the Figma MCP Catalog.", false, ), Self::AuthUnavailable => ( ErrorCode::DevupAuthRequired, - "Figma direct 연결 인증을 사용할 수 없습니다.", + "Figma direct connection authentication is unavailable.", false, ), Self::CapabilityUnavailable => ( ErrorCode::DevupFigmaDirectUnavailable, - "Figma direct 연결에 필요한 읽기 capability가 없습니다.", + "The read capability required for a Figma direct connection is missing.", false, ), Self::PermissionDenied => ( ErrorCode::DevupFigmaPermissionDenied, - "Figma 파일을 읽을 권한이 없습니다.", + "No permission to read this Figma file.", false, ), Self::RateLimited => ( ErrorCode::DevupFigmaRateLimited, - "Figma 요청 한도에 도달했습니다.", + "Figma request rate limit reached.", true, ), Self::NodeNotFound => ( ErrorCode::DevupFigmaNodeNotFound, - "Figma node를 찾지 못했습니다.", + "Figma node not found.", false, ), Self::VersionChanged => ( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during collection.", true, ), Self::Transport => ( ErrorCode::DevupFigmaDirectUnavailable, - "Figma direct 연결을 완료하지 못했습니다.", + "Failed to complete the Figma direct connection.", true, ), Self::InvalidResponse => ( ErrorCode::DevupSnapshotUnsupported, - "Figma MCP 응답을 안전하게 해석하지 못했습니다.", + "Failed to safely interpret the Figma MCP response.", false, ), }; let mut details = json!({ "source": "direct", "status": status }); if self == Self::CatalogRejected { details["options"] = json!([ - "Figma MCP Catalog waitlist에 devup-mcp 등록: https://www.figma.com/mcp-catalog/", - "devup_figma_auth { action: \"configure\", clientId, clientSecret }로 직접 확보한 client 자격증명 주입", - "Figma 데스크톱 앱의 로컬 Dev Mode MCP 사용 (OAuth 불필요)", - "호스트에 등록된 공식 Figma MCP로 handoff (sourcePolicy: auto 또는 host, 현재 기본 폴백)" + "Register devup-mcp on the Figma MCP Catalog waitlist: https://www.figma.com/mcp-catalog/", + "Inject client credentials you obtained yourself via devup_figma_auth { action: \"configure\", clientId, clientSecret }", + "Use the local Dev Mode MCP in the Figma desktop app (no OAuth needed)", + "Hand off to the official Figma MCP registered on the host (sourcePolicy: auto or host, the current default fallback)" ]); } DevupError::with_details(code, message, retryable, details) diff --git a/crates/devup-mcp-figma/src/url.rs b/crates/devup-mcp-figma/src/url.rs index 50a6125..82bf7e1 100644 --- a/crates/devup-mcp-figma/src/url.rs +++ b/crates/devup-mcp-figma/src/url.rs @@ -15,11 +15,11 @@ pub struct FigmaTarget { impl FigmaTarget { pub fn parse(input: &str) -> Result { let url = Url::parse(input) - .map_err(|_| DevupError::unsupported_file("올바른 Figma 링크가 아닙니다."))?; + .map_err(|_| DevupError::unsupported_file("Not a valid Figma link."))?; if url.scheme() != "https" || !matches!(url.host_str(), Some("figma.com" | "www.figma.com")) { return Err(DevupError::unsupported_file( - "HTTPS Figma 디자인 링크만 사용할 수 있습니다.", + "Only HTTPS Figma design links are supported.", )); } @@ -34,7 +34,7 @@ impl FigmaTarget { } _ => { return Err(DevupError::unsupported_file( - "지원하는 Figma design, file 또는 branch 링크가 아닙니다.", + "Not a supported Figma design, file, or branch link.", )); } }; @@ -65,7 +65,7 @@ fn validate_key(key: &str) -> Result<(), DevupError> { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) { return Err(DevupError::unsupported_file( - "Figma 파일 또는 브랜치 키 형식이 올바르지 않습니다.", + "Figma file or branch key format is invalid.", )); } Ok(()) @@ -78,7 +78,7 @@ fn normalize_node_id(node_id: &str) -> Result { format!("{left}:{right}") } else { return Err(DevupError::unsupported_file( - "Figma node-id 형식이 올바르지 않습니다.", + "Figma node-id format is invalid.", )); }; @@ -90,7 +90,7 @@ fn normalize_node_id(node_id: &str) -> Result { && right.bytes().all(|byte| byte.is_ascii_digit())); if !valid { return Err(DevupError::unsupported_file( - "Figma node-id 형식이 올바르지 않습니다.", + "Figma node-id format is invalid.", )); } Ok(normalized) diff --git a/crates/devup-mcp-figma/src/variables.rs b/crates/devup-mcp-figma/src/variables.rs index 7c16c29..c7c8717 100644 --- a/crates/devup-mcp-figma/src/variables.rs +++ b/crates/devup-mcp-figma/src/variables.rs @@ -125,7 +125,7 @@ pub(crate) fn merge_variable_results( let mut style = styles_by_id.remove(&style_ref.id).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma style이 삭제되거나 변경되었습니다.", + "A Figma style was deleted or changed during collection.", true, ) })?; @@ -271,7 +271,7 @@ fn expand_consumer_entry(entry: Value) -> Result { fn incomplete_consumers() -> DevupError { DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "수집 중 Figma style consumer 목록이 변경되었습니다.", + "The Figma style consumer list changed during collection.", true, ) } @@ -307,7 +307,7 @@ where fn invalid_variable_result() -> DevupError { DevupError::new( ErrorCode::DevupThemeConflict, - "Figma MCP 응답에서 변수/style batch를 찾지 못했습니다.", + "variable/style batch not found in the Figma MCP response.", false, ) } diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 962ab64..c0034f4 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -582,7 +582,7 @@ fn official_top_level_pages() -> UpstreamResult { raw: json!({ "content": [{ "type": "text", - "text": "No nodeId was provided. Listing the top-level pages of the document. Call get_metadata again with one of the page ids below (or any node id underneath) to get the XML metadata for that subtree.\n\nTop-level pages of the document:\n- 0:1: 표지\n- 12:34: 본문: 교정" + "text": "No nodeId was provided. Listing the top-level pages of the document. Call get_metadata again with one of the page ids below (or any node id underneath) to get the XML metadata for that subtree.\n\nTop-level pages of the document:\n- 0:1: Cover\n- 12:34: Body: Proofread" }] }), } @@ -600,7 +600,7 @@ fn file_page_metadata() -> UpstreamResult { { "id": "0:1", "type": "PAGE", - "name": "표지", + "name": "Cover", "childrenIds": ["1:2"], "descendantCount": 1 }, @@ -681,7 +681,7 @@ fn metadata_only_file_collection_completes_without_snapshot_calls() { second.raw["structuredContent"]["devupMetadata"]["nodes"] = json!([{ "id": "12:34", "type": "PAGE", - "name": "본문: 교정", + "name": "Body: Proofread", "childrenIds": [], "descendantCount": 0 }]); @@ -1373,7 +1373,7 @@ fn node_snapshot_follows_the_compiled_cursor_until_complete() { raw: json!({ "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], "nodes": [ - {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "완료", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "Done", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, {"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"offset":0,"nextOffset": 2, "complete": true, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} ], "diagnostics": [] }), diff --git a/crates/devup-mcp-figma/tests/explore.rs b/crates/devup-mcp-figma/tests/explore.rs index f460675..b55e924 100644 --- a/crates/devup-mcp-figma/tests/explore.rs +++ b/crates/devup-mcp-figma/tests/explore.rs @@ -40,10 +40,10 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { raw_node( "1:1", "FRAME", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 1200.0, 80.0], 1, - "본연체", + "Essence", ), raw_node( "1:2", @@ -51,7 +51,7 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "A : STORY-F-PROOFREAD", [0.0, 120.0, 360.0, 740.0], 12, - "이야기가 글로 정리되었어요", + "Your story has been written up", ), raw_node( "1:3", @@ -59,7 +59,7 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "A : STORY-F-PROOFREAD", [400.0, 120.0, 360.0, 740.0], 13, - "공개 설정 나만 보기", + "Visibility: only me", ), raw_node( "1:4", @@ -67,15 +67,15 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "Annotation", [800.0, 140.0, 180.0, 40.0], 0, - "개발 참고", + "Dev note", ), raw_node( "2:1", "FRAME", - "[FR-027] 다음 기능", + "[FR-027] Next feature", [0.0, 1000.0, 1200.0, 80.0], 1, - "다음 기능", + "Next feature", ), raw_node( "2:2", @@ -83,7 +83,7 @@ fn projection(nodes_reversed: bool, truncated: bool) -> Snapshot { "A : NEXT", [0.0, 1120.0, 360.0, 740.0], 8, - "다음 화면", + "Next screen", ), ]; if nodes_reversed { @@ -149,7 +149,7 @@ fn nested_wquw_section_projection() -> Snapshot { let mut section = raw_node( "4217:7743", "SECTION", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 4_400.0, 900.0], 11, "", @@ -163,10 +163,10 @@ fn nested_wquw_section_projection() -> Snapshot { let mut heading = raw_node( "3879:35481", "FRAME", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 1_200.0, 80.0], 1, - "본연체", + "Essence", ); heading .fields @@ -219,10 +219,10 @@ fn classification_distinguishes_heading_screen_annotation_and_container() { let heading = ExploreNode::try_from(&raw_node( "1:1", "FRAME", - "[FR-026] 본연체", + "[FR-026] Essence", [0.0, 0.0, 1200.0, 80.0], 1, - "본연체", + "Essence", )) .unwrap(); let screen = ExploreNode::try_from(&raw_node( @@ -231,7 +231,7 @@ fn classification_distinguishes_heading_screen_annotation_and_container() { "Screen", [0.0, 120.0, 360.0, 740.0], 12, - "화면", + "Screen", )) .unwrap(); let annotation = ExploreNode::try_from(&raw_node( @@ -240,7 +240,7 @@ fn classification_distinguishes_heading_screen_annotation_and_container() { "Note", [0.0, 120.0, 120.0, 30.0], 0, - "참고", + "Note", )) .unwrap(); let container = ExploreNode::try_from(&raw_node( @@ -269,7 +269,7 @@ fn heading_group_keeps_duplicate_states_and_stops_at_the_next_heading() { .unwrap(); assert_eq!(result.anchor.kind, ExploreKind::Heading); - assert_eq!(result.group.as_ref().unwrap().title, "[FR-026] 본연체"); + assert_eq!(result.group.as_ref().unwrap().title, "[FR-026] Essence"); assert_eq!( result .candidates diff --git a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs index 20f4c3f..778e62a 100644 --- a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs +++ b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs @@ -110,7 +110,7 @@ test("a nested heading explores the same ten screens as its enclosing SECTION", const heading = sceneNode({ id: "3879:35481", type: "TEXT", - name: "[FR-026] 본연체", + name: "[FR-026] Essence", width: 320, height: 48, }); @@ -118,7 +118,7 @@ test("a nested heading explores the same ten screens as its enclosing SECTION", const section = sceneNode({ id: "4217:7743", type: "SECTION", - name: "[FR-026] 본연체", + name: "[FR-026] Essence", width: 4_400, height: 900, children: [heading, wrapper], @@ -160,7 +160,7 @@ test("a large SECTION without screens visits at most projectionLimit times eight }); test("oversized required nodes collapse to a bounded required-only projection", async () => { - const longName = "가".repeat(2_000); + const longName = "A".repeat(2_000); const anchor = sceneNode({ id: "anchor", type: "SECTION", name: longName }); let nested = anchor; for (let index = 0; index < 10; index += 1) { diff --git a/crates/devup-mcp-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index 9a3fbda..64b8567 100644 --- a/crates/devup-mcp-figma/tests/upstream_contract.rs +++ b/crates/devup-mcp-figma/tests/upstream_contract.rs @@ -197,7 +197,7 @@ fn search_uses_a_compiled_read_only_page_projection() { "file-key", "0:1", SearchReadOptions { - query: "본연체".to_owned(), + query: "Essence".to_owned(), node_types: vec!["FRAME".to_owned()], match_kind: "normalized".to_owned(), limit: 20, @@ -208,7 +208,7 @@ fn search_uses_a_compiled_read_only_page_projection() { assert_eq!(call.tool_name(), "use_figma"); assert!(code.contains("figma.setCurrentPageAsync(page)")); assert!(code.contains("page.findAll")); - assert!(code.contains("본연체")); + assert!(code.contains("Essence")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); } diff --git a/crates/devup-mcp-figma/tests/used_resources.rs b/crates/devup-mcp-figma/tests/used_resources.rs index 0afc285..a92aa73 100644 --- a/crates/devup-mcp-figma/tests/used_resources.rs +++ b/crates/devup-mcp-figma/tests/used_resources.rs @@ -64,7 +64,7 @@ fn scanner_collects_bound_variables_and_every_supported_style_field() { "gridStyleId": "S:grid", "backgroundStyleId": "S:background", "styledTextSegments": [{ - "characters": "[1. 이름]", + "characters": "[1. Name]", "textStyleId": "S:text-emphasis", "boundVariables": { "fills": [{"type": "VARIABLE_ALIAS", "id": "VariableID:90:12"}] diff --git a/crates/devup-mcp-visual/src/lib.rs b/crates/devup-mcp-visual/src/lib.rs index a806dbe..3d057f0 100644 --- a/crates/devup-mcp-visual/src/lib.rs +++ b/crates/devup-mcp-visual/src/lib.rs @@ -63,9 +63,9 @@ pub enum VisualError { impl std::fmt::Display for VisualError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Image(error) => write!(formatter, "PNG를 읽거나 쓸 수 없습니다: {error}"), + Self::Image(error) => write!(formatter, "Could not read or write the PNG: {error}"), Self::InvalidThreshold => { - formatter.write_str("max_changed_ratio는 0 이상 1 이하여야 합니다.") + formatter.write_str("max_changed_ratio must be between 0 and 1 inclusive.") } } } diff --git a/crates/devup-mcp-visual/src/main.rs b/crates/devup-mcp-visual/src/main.rs index 067a8b7..cfd24c1 100644 --- a/crates/devup-mcp-visual/src/main.rs +++ b/crates/devup-mcp-visual/src/main.rs @@ -30,7 +30,7 @@ fn run(arguments: Vec) -> Result { let option = arguments[index].as_str(); let value = arguments .get(index + 1) - .ok_or_else(|| format!("{option} 값이 필요합니다."))?; + .ok_or_else(|| format!("{option} requires a value."))?; match option { "--reference" => reference = Some(PathBuf::from(value)), "--actual" => actual = Some(PathBuf::from(value)), @@ -38,20 +38,20 @@ fn run(arguments: Vec) -> Result { "--channel-tolerance" => { options.channel_tolerance = value .parse() - .map_err(|_| "channel tolerance가 올바르지 않습니다.".to_owned())?; + .map_err(|_| "channel tolerance is not a valid value.".to_owned())?; } "--max-changed-ratio" => { options.max_changed_ratio = value .parse() - .map_err(|_| "max changed ratio가 올바르지 않습니다.".to_owned())?; + .map_err(|_| "max changed ratio is not a valid value.".to_owned())?; } - _ => return Err(format!("알 수 없는 option입니다: {option}")), + _ => return Err(format!("Unsupported option: {option}")), } index += 2; } let report = compare_png( - reference.ok_or_else(|| "--reference가 필요합니다.".to_owned())?, - actual.ok_or_else(|| "--actual이 필요합니다.".to_owned())?, + reference.ok_or_else(|| "--reference is required.".to_owned())?, + actual.ok_or_else(|| "--actual is required.".to_owned())?, &options, ) .map_err(|error| error.to_string())?; diff --git a/crates/devup-mcp/src/server/artifacts.rs b/crates/devup-mcp/src/server/artifacts.rs index b221d8a..e159108 100644 --- a/crates/devup-mcp/src/server/artifacts.rs +++ b/crates/devup-mcp/src/server/artifacts.rs @@ -655,7 +655,7 @@ impl ArtifactStore { { return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "resource output 이름 또는 MIME 형식이 올바르지 않습니다.", + "The resource output name or MIME type is invalid.", false, )); } @@ -669,7 +669,7 @@ impl ArtifactStore { { return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "resource output ID가 중복되었거나 올바르지 않습니다.", + "The resource output ID is duplicated or invalid.", true, )); } @@ -711,7 +711,7 @@ impl ArtifactStore { .ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "resource allocation 크기가 안전한 범위를 초과했습니다.", + "The resource allocation size exceeded the safe range.", false, ) })?; @@ -725,7 +725,7 @@ impl ArtifactStore { { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "resource output이 artifact 메모리 한도를 초과했습니다.", + "The resource output exceeded the artifact memory limit.", false, )); } @@ -737,7 +737,7 @@ impl ArtifactStore { if retained_bytes.saturating_add(allocation) > self.limits.max_total_bytes { return Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "resource output이 전체 메모리 한도를 초과했습니다.", + "The resource output exceeded the total memory limit.", false, )); } @@ -843,7 +843,7 @@ impl ArtifactStore { let bytes = serde_json::to_vec(&payload).map_err(|error| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("Figma artifact를 직렬화할 수 없습니다: {error}"), + format!("Cannot serialize the Figma artifact: {error}"), false, ) })?; @@ -853,7 +853,7 @@ impl ArtifactStore { { return Err(DevupError::with_details( ErrorCode::DevupFigmaResponseTooLarge, - "Figma artifact가 메모리 캐시 한도를 초과했습니다.", + "The Figma artifact exceeded the memory cache limit.", false, json!({"artifactBytes": bytes.len()}), )); @@ -1004,7 +1004,7 @@ fn output_chunk_ranges(bytes: &[u8], is_binary: bool) -> Result Result String { fn acquisition_cancelled() -> DevupError { DevupError::new( ErrorCode::DevupFigmaDirectUnavailable, - "동일 Figma artifact 수집이 완료되기 전에 취소되었습니다.", + "Collection of the same Figma artifact was cancelled before it completed.", true, ) } @@ -1051,7 +1051,7 @@ fn acquisition_cancelled() -> DevupError { fn resource_expired() -> DevupError { DevupError::new( ErrorCode::DevupFigmaHandoffExpired, - "resource artifact가 없거나 만료되었습니다.", + "The resource artifact is missing or expired.", true, ) } diff --git a/crates/devup-mcp/src/server/delivery.rs b/crates/devup-mcp/src/server/delivery.rs index 3e89ba8..d8e2b55 100644 --- a/crates/devup-mcp/src/server/delivery.rs +++ b/crates/devup-mcp/src/server/delivery.rs @@ -33,7 +33,7 @@ impl FromStr for DeliveryMode { "resource" => Ok(Self::Resource), _ => Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "delivery는 auto, inline 또는 resource여야 합니다.", + "delivery must be auto, inline, or resource.", false, )), } @@ -110,7 +110,7 @@ pub fn choose_delivery( total.checked_add(output.bytes.len()).ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "생성 output 크기가 안전한 범위를 초과했습니다.", + "The generated output size exceeded the safe range.", false, ) }) @@ -125,7 +125,7 @@ pub fn choose_delivery( }), DeliveryMode::Inline if total_bytes > MAX_INLINE_TOTAL_BYTES => Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "inline output이 1 MiB 상한을 초과했습니다. delivery=auto 또는 resource를 사용하세요.", + "The inline output exceeded the 1 MiB limit. Use delivery=auto or resource.", false, )), DeliveryMode::Inline => Ok(DeliveryDecision { inline: true }), @@ -145,7 +145,7 @@ fn projected_output_wire_bytes(output: &ProjectedOutput) -> Result Result MAX_INLINE_TOTAL_BYTES => Err(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "직렬화된 inline MCP response가 1 MiB 상한을 초과했습니다. delivery=auto 또는 resource를 사용하세요.", + "The serialized inline MCP response exceeded the 1 MiB limit. Use delivery=auto or resource.", false, )), DeliveryMode::Inline => Ok(DeliveryDecision { inline: true }), diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index 9da08c7..de6890a 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -202,7 +202,7 @@ impl HandoffStore { prune_expired(&mut state, now, self.limits.ttl.as_secs()); if state.sessions.len() >= self.limits.max_sessions { return Err(too_large( - "동시에 유지할 수 있는 Figma handoff session 수를 초과했습니다.", + "Exceeded the number of Figma handoff sessions that can be held at once.", )); } let session_id = unique_id(&state.sessions, &state.tombstones); @@ -273,12 +273,12 @@ impl HandoffStore { ) -> Result<(), DevupError> { let result = normalize_handoff_result(result)?; let encoded_len = serde_json::to_vec(&result) - .map_err(|_| invalid("Figma handoff result를 JSON으로 읽을 수 없습니다."))? + .map_err(|_| invalid("Cannot read the Figma handoff result as JSON."))? .len(); if encoded_len > self.limits.max_result_bytes { self.remove(session_id).await; return Err(too_large( - "Figma handoff result의 허용 크기를 초과했습니다.", + "The Figma handoff result exceeded the allowed size.", )); } @@ -291,7 +291,7 @@ impl HandoffStore { .saturating_sub(session.result_bytes); } return Err(too_large( - "Figma handoff result의 전체 메모리 한도를 초과했습니다.", + "Figma handoff results exceeded the total memory limit.", )); } let mut session = take_session(&mut state, session_id, now, self.limits.ttl.as_secs())?; @@ -303,7 +303,7 @@ impl HandoffStore { }; put_session(&mut state, session_id.to_owned(), session); return Err(invalid_reason( - "알 수 없거나 이미 처리한 Figma handoff call ID입니다.", + "Unknown or already-consumed Figma handoff call ID.", reason, )); }; @@ -375,7 +375,7 @@ fn take_session( Err(expired()) } else { Err(invalid_reason( - "존재하지 않는 Figma handoff session입니다.", + "No such Figma handoff session.", "unknown_session", )) }; @@ -477,7 +477,7 @@ fn invalid_reason(message: &str, reason: &str) -> DevupError { fn expired() -> DevupError { DevupError::with_details( ErrorCode::DevupFigmaHandoffExpired, - "Figma handoff session이 만료되었습니다.", + "The Figma handoff session has expired.", true, json!({"source": "host", "reason": "expired"}), ) @@ -505,13 +505,13 @@ fn detect_tool_mismatch(requested_tool: &str, call_id: &str, value: &Value) -> O } Some(DevupError::with_details( ErrorCode::DevupFigmaHandoffInvalid, - "요청한 도구가 아닌 다른 Figma 도구의 결과로 보입니다.", + "This looks like the result of a different Figma tool than the one requested.", false, json!({ "reason": "tool_mismatch", "requested": { "tool": requested_tool, "callId": call_id }, - "hint": "요청한 도구가 아닌 다른 도구의 결과로 보입니다. calls[].tool 을 그대로 실행하세요.", - "doNot": "다른 Figma 도구로 대체하거나, 결과를 가공해 형식을 맞추려 하지 마세요." + "hint": "This looks like the result of a tool other than the one requested. Run calls[].tool exactly as given.", + "doNot": "Do not substitute another Figma tool, and do not reshape the result to fit the expected format." }), )) } @@ -626,17 +626,17 @@ fn has_usable_content_item(item: &Value) -> bool { fn missing_structured_content_error(value: &Value) -> DevupError { DevupError::with_details( ErrorCode::DevupFigmaHandoffInvalid, - "Figma handoff 결과에서 사용할 수 있는 content나 structuredContent를 찾지 못했습니다.", + "Found no usable content or structuredContent in the Figma handoff result.", false, json!({ "reason": "missing_structured_content", "expectedSchema": { "content": [{ "type": "text", "text": "" }], - "structuredContent": { "devupMetadata": "" } + "structuredContent": { "devupMetadata": "" } }, "receivedShape": received_shape(value), - "howToFix": "공식 Figma MCP 응답을 가공하지 말고 원본 그대로 넘겨라. 호스트가 텍스트만 노출한다면 sourcePolicy 또는 수집 경로를 바꿔야 한다.", - "doNot": "봉투 필드를 추측해서 만들어 넣지 마라." + "howToFix": "Pass the official Figma MCP response through verbatim, without processing it. If the host exposes text only, change sourcePolicy or the collection path.", + "doNot": "Do not guess and fabricate envelope fields." }), ) } diff --git a/crates/devup-mcp/src/server/output.rs b/crates/devup-mcp/src/server/output.rs index 537c128..f23ec42 100644 --- a/crates/devup-mcp/src/server/output.rs +++ b/crates/devup-mcp/src/server/output.rs @@ -82,18 +82,22 @@ impl CommitHook for NoopCommitHook {} impl OutputPolicy { pub fn from_roots(roots: Vec) -> Result { if roots.is_empty() { - return Err(invalid_path("허용할 output root가 하나 이상 필요합니다.")); + return Err(invalid_path( + "At least one allowed output root is required.", + )); } let mut opened = Vec::with_capacity(roots.len()); for root in roots { let display_path = dunce::canonicalize(&root).map_err(|error| { - invalid_path(format!("output root를 확인할 수 없습니다: {error}")) + invalid_path(format!("Cannot resolve the output root: {error}")) })?; if !display_path.is_dir() { - return Err(invalid_path("output root는 존재하는 폴더여야 합니다.")); + return Err(invalid_path( + "The output root must be an existing directory.", + )); } let dir = Dir::open_ambient_dir(&display_path, ambient_authority()) - .map_err(|error| invalid_path(format!("output root를 열 수 없습니다: {error}")))?; + .map_err(|error| invalid_path(format!("Cannot open the output root: {error}")))?; opened.push(Arc::new(OutputRoot { dir, display_path })); } Ok(Self { @@ -104,7 +108,7 @@ impl OutputPolicy { pub fn resolve(&self, requested: &str) -> Result { let path = Path::new(requested); if requested.trim().is_empty() { - return Err(invalid_path("outputPath는 파일 경로여야 합니다.")); + return Err(invalid_path("outputPath must be a file path.")); } let (root, relative_path) = if path.is_absolute() { @@ -115,7 +119,7 @@ impl OutputPolicy { .ok() .map(|relative| (root.clone(), relative.to_path_buf())) }) - .ok_or_else(|| invalid_path("outputPath가 허용된 root 밖에 있습니다."))? + .ok_or_else(|| invalid_path("outputPath is outside the allowed root."))? } else { (self.roots[0].clone(), path.to_path_buf()) }; @@ -146,7 +150,7 @@ impl OutputTransaction { ) -> Result<(), DevupError> { if !self.targets.insert(target.display_path.clone()) { return Err(invalid_path( - "둘 이상의 output이 같은 파일 경로를 사용할 수 없습니다.", + "Two or more outputs cannot use the same file path.", )); } let parent = target @@ -154,7 +158,9 @@ impl OutputTransaction { .parent() .unwrap_or_else(|| Path::new("")); target.root.dir.create_dir_all(parent).map_err(|error| { - transaction_error(format!("output 상위 폴더를 만들 수 없습니다: {error}")) + transaction_error(format!( + "Cannot create the output parent directory: {error}" + )) })?; reject_existing_symlink_ancestors(&target.root, &target.relative_path)?; let temp_path = unique_sibling(&target.relative_path, "tmp"); @@ -163,13 +169,13 @@ impl OutputTransaction { .dir .open_with(&temp_path, OpenOptions::new().write(true).create_new(true)) .map_err(|error| { - transaction_error(format!("output staging 파일을 만들 수 없습니다: {error}")) + transaction_error(format!("Cannot create the output staging file: {error}")) })?; if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { drop(file); let _ = target.root.dir.remove_file(&temp_path); return Err(transaction_error(format!( - "output staging 파일을 기록할 수 없습니다: {error}" + "Cannot write the output staging file: {error}" ))); } drop(file); @@ -202,14 +208,14 @@ impl OutputTransaction { { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err(transaction_error( - "output target은 일반 파일이거나 아직 존재하지 않아야 합니다.", + "The output target must be a regular file or not exist yet.", )); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(transaction_error(format!( - "output target을 확인할 수 없습니다: {error}" + "Cannot inspect the output target: {error}" ))); } } @@ -220,7 +226,7 @@ impl OutputTransaction { let rollback = self.rollback(hook); return if rollback.failures.is_empty() { Err(transaction_error(format!( - "output transaction commit에 실패했습니다: {error}" + "The output transaction commit failed: {error}" ))) } else { Err(transaction_rollback_error(error, rollback)) @@ -325,7 +331,7 @@ impl OutputTransaction { }) } else { Err(std::io::Error::other( - "replacement target를 제거하지 못해 backup을 복원하지 않았습니다.", + "Did not restore the backup because the replacement target could not be removed.", )) }; if let Err(error) = restore { @@ -389,17 +395,15 @@ fn normalize_relative_file(path: &Path) -> Result { Component::Normal(value) if safe_component(value) => normalized.push(value), Component::CurDir => {} Component::Normal(_) => { - return Err(invalid_path( - "outputPath에 안전하지 않은 파일명이 있습니다.", - )); + return Err(invalid_path("outputPath contains an unsafe file name.")); } Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(invalid_path("outputPath는 허용 root를 벗어날 수 없습니다.")); + return Err(invalid_path("outputPath cannot escape the allowed root.")); } } } if normalized.as_os_str().is_empty() || normalized.file_name().is_none() { - return Err(invalid_path("outputPath는 파일 경로여야 합니다.")); + return Err(invalid_path("outputPath must be a file path.")); } Ok(normalized) } @@ -420,17 +424,17 @@ fn reject_existing_symlink_ancestors( match root.dir.symlink_metadata(¤t) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(invalid_path( - "outputPath 상위 경로의 symlink 또는 junction은 허용하지 않습니다.", + "A symlink or junction in an outputPath ancestor is not allowed.", )); } Ok(metadata) if !metadata.is_dir() => { - return Err(invalid_path("outputPath 상위 경로가 폴더가 아닙니다.")); + return Err(invalid_path("An outputPath ancestor is not a directory.")); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, Err(error) => { return Err(invalid_path(format!( - "outputPath 상위 경로를 확인할 수 없습니다: {error}" + "Cannot inspect an outputPath ancestor: {error}" ))); } } @@ -464,7 +468,7 @@ fn transaction_rollback_error( .collect::>(); DevupError::with_details( ErrorCode::DevupCodegenFailed, - format!("output transaction commit과 rollback에 실패했습니다: {commit_error}"), + format!("The output transaction commit and rollback both failed: {commit_error}"), false, json!({ "phase": "rollback", @@ -506,7 +510,7 @@ fn verify_fingerprint( Ok(()) } else { Err(std::io::Error::other( - "복원된 output의 길이 또는 hash가 원본 backup과 일치하지 않습니다.", + "The restored output's length or hash does not match the original backup.", )) } } diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs index 18586e5..945ff1b 100644 --- a/crates/devup-mcp/src/server/project_context.rs +++ b/crates/devup-mcp/src/server/project_context.rs @@ -41,7 +41,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result std::env::current_dir().map_err(|error| { DevupError::with_details( ErrorCode::DevupInvalidInput, - "현재 디렉터리를 확인하지 못했습니다.", + "Could not determine the current directory.", false, json!({ "ioError": error.to_string() }), ) @@ -66,7 +66,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result) -> Result std::env::current_dir().map_err(|error| { DevupError::with_details( ErrorCode::DevupInvalidInput, - "현재 디렉터리를 확인하지 못했습니다.", + "Could not determine the current directory.", false, json!({ "ioError": error.to_string() }), ) @@ -149,7 +149,7 @@ fn theme_scope(root: &Path, filter: Option<&str>) -> Value { files.dedup(); if files.is_empty() { return not_found_response( - "devup.json을 찾지 못했습니다. 색상·타이포그래피·길이·그림자 토큰 이름을 추측해서 코드를 작성하지 마세요.", + "No devup.json found. Do not write code by guessing color, typography, length, or shadow token names.", vec![display_path(&root.join("devup.json"))], ); } @@ -233,7 +233,7 @@ fn api_scope(root: &Path, filter: Option<&str>) -> Value { let files = find_files_named(root, "openapi.json", 4); if files.is_empty() { return not_found_response( - "openapi.json을 찾지 못했습니다. API 엔드포인트나 스키마 이름을 추측해서 코드를 작성하지 마세요.", + "No openapi.json found. Do not write code by guessing API endpoint or schema names.", vec![format!("{} (up to depth 4)", display_path(root))], ); } @@ -373,7 +373,7 @@ fn db_scope(root: &Path, filter: Option<&str>) -> Value { model_files.dedup(); if model_files.is_empty() { return not_found_response( - "Vespertide 모델(models/*.json)을 찾지 못했습니다. 테이블·컬럼 이름이나 타입을 추측해서 코드를 작성하지 마세요.", + "No Vespertide models (models/*.json) found. Do not write code by guessing table or column names or types.", vec![format!( "{} (models/*.json, up to depth 4)", display_path(root) diff --git a/crates/devup-mcp/src/server/project_root.rs b/crates/devup-mcp/src/server/project_root.rs index 81f7cca..2d7c78c 100644 --- a/crates/devup-mcp/src/server/project_root.rs +++ b/crates/devup-mcp/src/server/project_root.rs @@ -159,7 +159,7 @@ pub fn not_found_response(message: impl Into, searched_paths: Vec Result, DevupError> { serde_json::to_vec(value).map_err(|error| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("resource output을 JSON으로 직렬화할 수 없습니다: {error}"), + format!("Cannot serialize the resource output to JSON: {error}"), false, ) }) @@ -130,7 +130,7 @@ pub(super) async fn apply_delivery( let result = result.as_object_mut().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "resource delivery 결과가 JSON object가 아닙니다.", + "The resource delivery result is not a JSON object.", false, ) })?; @@ -211,7 +211,7 @@ fn materialize_asset_resource_references( .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "asset resource에 대응하는 manifest 항목이 없습니다.", + "No manifest entry matches this asset resource.", false, ) })?; @@ -234,7 +234,7 @@ fn materialize_asset_resource_references( encode_projected_json(result.get("assetManifest").ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "asset manifest resource가 없습니다.", + "The asset manifest resource is missing.", false, ) })?)?; @@ -251,35 +251,35 @@ fn projected_asset_outputs(manifest: &AssetManifest) -> Result Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "내부 수집 operation은 MCP artifact로 완료할 수 없습니다.", + "An internal collect operation cannot be completed from an MCP artifact.", false, )), } diff --git a/crates/devup-mcp/src/server/stack_diff.rs b/crates/devup-mcp/src/server/stack_diff.rs index eb042c3..0a19918 100644 --- a/crates/devup-mcp/src/server/stack_diff.rs +++ b/crates/devup-mcp/src/server/stack_diff.rs @@ -46,7 +46,7 @@ pub async fn run(project_root: Option<&str>, layers: &[String]) -> Result, layers: &[String]) -> Result std::env::current_dir().map_err(|error| { DevupError::with_details( ErrorCode::DevupInvalidInput, - "현재 디렉터리를 확인하지 못했습니다.", + "Could not determine the current directory.", false, json!({ "ioError": error.to_string() }), ) @@ -106,7 +106,7 @@ fn db_entity_layer(model_dirs: &[PathBuf]) -> Value { if model_dirs.is_empty() { return json!({ "checked": false, - "reason": "models/ 디렉터리를 찾지 못했습니다 (Vespertide 모델 없음).", + "reason": "No models/ directory found (no Vespertide models).", "drifts": [], }); } @@ -148,7 +148,7 @@ fn db_entity_layer(model_dirs: &[PathBuf]) -> Value { "table": table, "kind": "entity-not-generated", "message": format!( - "{table} 모델에 대응하는 sea-orm entity({})를 찾지 못했습니다. `vespertide export --orm seaorm`을 실행했는지 확인하세요.", + "No sea-orm entity ({}) found for the {table} model. Check that `vespertide export --orm seaorm` was run.", display_path(&entity_path) ), "confidence": "low", @@ -243,7 +243,7 @@ fn entity_route_layer(root: &Path, model_dirs: &[PathBuf]) -> Value { if model_dirs.is_empty() { return json!({ "checked": false, - "reason": "models/ 디렉터리를 찾지 못했습니다 (Vespertide 모델 없음).", + "reason": "No models/ directory found (no Vespertide models).", "drifts": [], }); } @@ -262,7 +262,7 @@ fn entity_route_layer(root: &Path, model_dirs: &[PathBuf]) -> Value { drifts.push(json!({ "kind": "no-routes-dir", "message": format!( - "{}에 라우트 파일이 없어 entity-route 대응을 확인할 수 없습니다.", + "No route files under {}, so entity-route correspondence cannot be checked.", display_path(&routes_dir) ), "confidence": "low", @@ -299,7 +299,7 @@ fn entity_route_layer(root: &Path, model_dirs: &[PathBuf]) -> Value { "column": column_name, "kind": "column-never-referenced-in-routes", "message": format!( - "{table}.{column_name}을(를) 참조하는 라우트를 찾지 못했습니다. 의도적으로 내부 전용 컬럼일 수 있습니다." + "No route referencing {table}.{column_name} was found. It may be an intentionally internal-only column." ), "confidence": "low", })); @@ -387,7 +387,7 @@ fn route_openapi_layer(root: &Path) -> Value { if routes_dirs.is_empty() && openapi_files.is_empty() { return json!({ "checked": false, - "reason": "src/routes/도, openapi.json도 찾지 못했습니다.", + "reason": "Found neither src/routes/ nor openapi.json.", "drifts": [], }); } @@ -427,7 +427,7 @@ fn route_openapi_layer(root: &Path) -> Value { if routes_dirs.is_empty() { return json!({ "checked": false, - "reason": "src/routes/를 찾지 못해 코드 쪽 라우트를 확인할 수 없습니다.", + "reason": "No src/routes/ found, so the code-side routes cannot be checked.", "openapiSpecsFound": specs_checked, "drifts": [], }); @@ -435,7 +435,7 @@ fn route_openapi_layer(root: &Path) -> Value { if openapi_files.is_empty() { return json!({ "checked": false, - "reason": "openapi.json을 찾지 못해 스펙과 비교할 수 없습니다.", + "reason": "No openapi.json found, so there is no spec to compare against.", "codeRoutesFound": code_routes.len(), "drifts": [], }); @@ -454,7 +454,7 @@ fn route_openapi_layer(root: &Path) -> Value { if !stale_spec.is_empty() { drifts.push(json!({ "kind": "route-missing-from-openapi", - "message": "코드에 있는 라우트가 openapi.json에 없습니다. 스펙이 낡았을 수 있습니다 (재빌드 필요).", + "message": "A route present in the code is missing from openapi.json. The spec may be stale (rebuild needed).", "routes": stale_spec, "confidence": "medium", })); @@ -462,7 +462,7 @@ fn route_openapi_layer(root: &Path) -> Value { if !stale_code_or_merged.is_empty() { drifts.push(json!({ "kind": "openapi-path-not-found-in-scanned-routes", - "message": "openapi.json에 있는 경로를 스캔한 라우트 파일에서 찾지 못했습니다. merge된 하위 앱이거나 라우트 매크로 형식이 달라 스캔이 놓쳤을 수 있습니다.", + "message": "A path in openapi.json was not found in the scanned route files. It may come from a merged sub-app, or the scan may have missed a non-standard route macro form.", "routes": stale_code_or_merged, "confidence": "low", })); @@ -632,14 +632,14 @@ fn openapi_client_layer(root: &Path) -> Value { if ts_files.is_empty() { return json!({ "checked": false, - "reason": "프론트엔드 .ts/.tsx 파일을 찾지 못했습니다.", + "reason": "No frontend .ts/.tsx files found.", "drifts": [], }); } if openapi_files.is_empty() { return json!({ "checked": false, - "reason": "openapi.json을 찾지 못해 프론트엔드 호출을 검증할 수 없습니다.", + "reason": "No openapi.json found, so frontend calls cannot be verified.", "drifts": [], }); } @@ -682,7 +682,7 @@ fn openapi_client_layer(root: &Path) -> Value { "file": relative_or_absolute(root, file), "callSite": call_site, "identifier": identifier, - "message": "프론트엔드가 호출하는 엔드포인트/operationId를 openapi.json에서 찾지 못했습니다.", + "message": "The endpoint/operationId the frontend calls was not found in openapi.json.", "confidence": "low", })); } diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index 48eeb0d..fc2102c 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -55,7 +55,7 @@ pub(super) fn validate_artifact_projection( Err(DevupError::with_details( ErrorCode::DevupFigmaHandoffInvalid, - "artifact capture capability가 요청한 export 범위를 충족하지 않습니다.", + "The artifact capture capability does not cover the requested export scope.", false, json!({ "capabilities": capabilities, @@ -80,7 +80,7 @@ pub(super) fn validate_outputs(outputs: &[String]) -> Result<(), DevupError> { if outputs.is_empty() { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "outputs는 하나 이상이어야 합니다.", + "outputs must contain at least one entry.", false, )); } @@ -91,7 +91,7 @@ pub(super) fn validate_outputs(outputs: &[String]) -> Result<(), DevupError> { ) { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("지원하지 않는 export output입니다: {output}"), + format!("Unsupported export output: {output}"), false, )); } @@ -106,7 +106,7 @@ pub(super) fn parse_source_policy(policy: &str) -> Result Ok(SourcePolicy::Host), _ => Err(DevupError::new( ErrorCode::DevupFigmaHostRequired, - "sourcePolicy는 auto, direct 또는 host여야 합니다.", + "sourcePolicy must be auto, direct, or host.", false, )), } @@ -124,7 +124,7 @@ pub(super) fn parse_asset_requests( if requests.len() > 16 { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "한 번에 export할 asset은 16개 이하여야 합니다.", + "At most 16 assets can be exported at once.", false, )); } @@ -139,7 +139,7 @@ pub(super) fn parse_asset_requests( { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "assetRequests의 ID, scale 또는 중복 값이 올바르지 않습니다.", + "An assetRequests ID, scale, or duplicate entry is invalid.", false, )); } @@ -151,7 +151,7 @@ pub(super) fn parse_asset_requests( _ => { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "asset format은 png, jpg, svg 또는 pdf여야 합니다.", + "asset format must be png, jpg, svg, or pdf.", false, )); } @@ -175,7 +175,7 @@ pub(super) fn parse_collection_scope(scope: &str) -> Result Ok(CollectionScope::File), _ => Err(DevupError::new( ErrorCode::DevupThemeConflict, - "scope는 node, page 또는 file이어야 합니다.", + "scope must be node, page, or file.", false, )), } @@ -187,7 +187,7 @@ pub(super) fn parse_root_layout(root_layout: &str) -> Result Ok(RootLayout::Embedded), _ => Err(DevupError::new( ErrorCode::DevupThemeConflict, - "rootLayout은 standalone 또는 embedded여야 합니다.", + "rootLayout must be standalone or embedded.", false, )), } diff --git a/crates/devup-mcp/tests/figma_explore.rs b/crates/devup-mcp/tests/figma_explore.rs index 4c69b0b..44f34b1 100644 --- a/crates/devup-mcp/tests/figma_explore.rs +++ b/crates/devup-mcp/tests/figma_explore.rs @@ -94,17 +94,17 @@ fn projection() -> Value { }, { "id": "1:1", "type": "FRAME", - "fields": {"name": "[FR-026] 본연체", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 0, "width": 1200, "height": 80, "childCount": 1, "textPreview": "본연체"}, + "fields": {"name": "[FR-026] Base Style", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 0, "width": 1200, "height": 80, "childCount": 1, "textPreview": "Base Style"}, "extra": {}, "fieldErrors": {} }, { "id": "1:2", "type": "FRAME", - "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 120, "width": 360, "height": 740, "childCount": 12, "textPreview": "이야기가 글로 정리되었어요"}, + "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 0, "y": 120, "width": 360, "height": 740, "childCount": 12, "textPreview": "Your story has been written up"}, "extra": {}, "fieldErrors": {} }, { "id": "1:3", "type": "FRAME", - "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 400, "y": 120, "width": 360, "height": 740, "childCount": 13, "textPreview": "공개 설정 나만 보기"}, + "fields": {"name": "A : STORY-F-PROOFREAD", "parentId": "0:1", "childrenIds": [], "x": 400, "y": 120, "width": 360, "height": 740, "childCount": 13, "textPreview": "Visibility: only me"}, "extra": {}, "fieldErrors": {} } ], diff --git a/crates/devup-mcp/tests/ground_truth_tools.rs b/crates/devup-mcp/tests/ground_truth_tools.rs index 6beaf30..dc168a5 100644 --- a/crates/devup-mcp/tests/ground_truth_tools.rs +++ b/crates/devup-mcp/tests/ground_truth_tools.rs @@ -135,7 +135,7 @@ async fn project_context_returns_stop_and_report_guardrail_when_devup_json_is_ab output["guardrail"]["message"] .as_str() .unwrap() - .contains("추측") + .contains("guessing") ); assert!( !output["guardrail"]["searchedPaths"] diff --git a/crates/devup-mcp/tests/handoff.rs b/crates/devup-mcp/tests/handoff.rs index 9e20b78..8fa0679 100644 --- a/crates/devup-mcp/tests/handoff.rs +++ b/crates/devup-mcp/tests/handoff.rs @@ -617,7 +617,7 @@ async fn accept_rejects_empty_content_with_a_schema_shaped_error_that_leaks_no_v error.details["expectedSchema"]["structuredContent"]["devupMetadata"] .as_str() .unwrap() - .contains("필수") + .contains("required") ); assert_eq!( error.details["receivedShape"]["topLevelKeys"], @@ -628,7 +628,7 @@ async fn accept_rejects_empty_content_with_a_schema_shaped_error_that_leaks_no_v !error.details["howToFix"].as_str().unwrap().is_empty(), "must tell the agent what to do next, not just that it failed" ); - assert!(error.details["doNot"].as_str().unwrap().contains("추측")); + assert!(error.details["doNot"].as_str().unwrap().contains("guess")); } /// Non-empty but still unusable content (an image block with no `data`, a diff --git a/crates/devup-mcp/tests/resource_delivery.rs b/crates/devup-mcp/tests/resource_delivery.rs index 328a98b..b1eac5a 100644 --- a/crates/devup-mcp/tests/resource_delivery.rs +++ b/crates/devup-mcp/tests/resource_delivery.rs @@ -241,7 +241,7 @@ async fn resource_protocol_lists_manifests_and_round_trips_chunks() -> anyhow::R payload(), ) .await?; - let original = "가나다".repeat(100_000).into_bytes(); + let original = "€€€".repeat(100_000).into_bytes(); let attached = store .attach_outputs( &artifact.artifact_id, diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index d319a94..3a9d120 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -100,15 +100,15 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o ); assert_eq!( selection["nextAction"]["why"], - "이 링크는 Section이며 내부에 화면이 여러 개 있습니다. 한 번에 전부 수집하면 크기 한도를 넘습니다." + "This link is a Section and holds several screens inside. Collecting them all at once exceeds the size limit." ); assert_eq!( selection["nextAction"]["how"], - "screens[] 중 대상 화면의 canonicalUrl 로 재호출하거나, 전부 필요하면 allScreens:true 를 쓰세요." + "Call again with the target screen's canonicalUrl from screens[], or use allScreens:true if you need every screen." ); assert_eq!( selection["nextAction"]["doNot"], - "Section 전체를 한 번에 수집하려 하지 마세요." + "Do not try to collect the whole Section at once." ); assert_eq!(upstream.0.load(Ordering::SeqCst), 1); let artifact_id = selection["cache"]["artifactId"].as_str().unwrap(); From 3ed2f63633b036d7727fd4f744b47c3981705565 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:00:40 +0900 Subject: [PATCH 21/69] feat(figma): make the direct Figma OAuth path work end to end Three defects each independently blocked `direct`, so the path had never completed a login. 1. Dynamic Client Registration always sent the literal client_name `devup-mcp`, which Figma's catalog allowlist rejects with a plain-text 403. The name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME and defaults to an allowlisted one; doctor reports the active value so a 403 is distinguishable from a network fault. 2. The client_secret issued by DCR was discarded (RegistrationResponse did not even deserialise the field). Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded. The secret is now kept next to the client_id it belongs to and used for both the authorization-code exchange and refresh. 3. auth_network_error dropped the reqwest error entirely, so every failure surfaced as the same sentence with details: null. It now carries kind/status/url/cause-chain, with the URL reduced to scheme+host+path so a query string cannot carry a code or token into a log. This is what made defect 2 findable. --- crates/devup-mcp-figma/src/credentials.rs | 21 ++- crates/devup-mcp-figma/src/lib.rs | 4 +- crates/devup-mcp-figma/src/oauth.rs | 168 +++++++++++++++++--- crates/devup-mcp-figma/tests/oauth_flow.rs | 157 +++++++++++++++++-- crates/devup-mcp/src/lib.rs | 105 +++++++++---- crates/devup-mcp/src/server/diagnostics.rs | 170 +++++++++++++++------ crates/devup-mcp/src/server/mod.rs | 58 +++---- crates/devup-mcp/tests/cli.rs | 84 +++++++++- crates/devup-mcp/tests/figma_doctor.rs | 30 +++- 9 files changed, 642 insertions(+), 155 deletions(-) diff --git a/crates/devup-mcp-figma/src/credentials.rs b/crates/devup-mcp-figma/src/credentials.rs index 5dfe9ca..b361901 100644 --- a/crates/devup-mcp-figma/src/credentials.rs +++ b/crates/devup-mcp-figma/src/credentials.rs @@ -10,6 +10,19 @@ use super::{DevupError, ErrorCode, SecretString}; #[serde(rename_all = "camelCase")] pub struct StoredAuthorization { pub client_id: String, + /// The secret issued alongside `client_id` by Dynamic Client + /// Registration, when the authorization server issues one. + /// + /// Figma's does: its metadata advertises only `client_secret_basic` and + /// `client_secret_post`, so a DCR-registered client is confidential and + /// every token/refresh request must carry the secret. It is kept next to + /// the `client_id` it belongs to rather than in the user-facing client + /// credential store, which holds credentials the operator supplied. + /// + /// `#[serde(default)]` keeps authorizations written before this field + /// existed readable from the keyring. + #[serde(default)] + pub client_secret: Option, pub access_token: String, pub refresh_token: Option, pub expires_at: Option, @@ -85,7 +98,7 @@ impl CredentialStore for KeyringCredentialStore { Ok(json) => serde_json::from_str(&json).map(Some).map_err(|_| { DevupError::new( ErrorCode::DevupAuthRequired, - "저장된 Figma 인증 정보를 읽을 수 없습니다. 다시 로그인하세요.", + "Cannot read the stored Figma credentials. Log in again.", false, ) }), @@ -118,7 +131,7 @@ impl CredentialStore for KeyringCredentialStore { fn keyring_error(_error: keyring::Error) -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "운영체제 보안 저장소에 Figma 인증 정보를 저장할 수 없습니다.", + "Cannot store Figma credentials in the OS secure store.", false, ) } @@ -126,7 +139,7 @@ fn keyring_error(_error: keyring::Error) -> DevupError { fn credential_task_error() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma 인증 저장소 작업을 완료하지 못했습니다.", + "Failed to complete the Figma credential store operation.", true, ) } @@ -208,7 +221,7 @@ impl ClientCredentialStore for KeyringClientCredentialStore { Ok(json) => serde_json::from_str(&json).map(Some).map_err(|_| { DevupError::new( ErrorCode::DevupAuthRequired, - "저장된 Figma client 자격증명을 읽을 수 없습니다. 다시 configure하세요.", + "Cannot read the stored Figma client credentials. Run configure again.", false, ) }), diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 1c38309..34d5ecd 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -42,8 +42,8 @@ pub use large_values::{ MAX_LARGE_VALUE_CHUNK_BYTES, }; pub use oauth::{ - AuthStatus, BrowserOpener, ClientCredentialSource, DirectPathSnapshot, OAuthManager, - SecretString, SystemBrowser, TokenState, + AuthStatus, BrowserOpener, ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, + OAuthManager, SecretString, SystemBrowser, TokenState, }; pub use payload::{ CollectedPayload, PayloadCompleteness, PayloadCompletenessReport, PayloadStructure, diff --git a/crates/devup-mcp-figma/src/oauth.rs b/crates/devup-mcp-figma/src/oauth.rs index 47e1b91..2a678c8 100644 --- a/crates/devup-mcp-figma/src/oauth.rs +++ b/crates/devup-mcp-figma/src/oauth.rs @@ -64,8 +64,35 @@ pub struct DirectPathSnapshot { pub token_state: TokenState, pub callback_port: Option, pub callback_port_free: Option, + /// The `client_name` Dynamic Client Registration would send right + /// now. Reported because Figma gates `/register` on this exact + /// string, so a 403 is otherwise indistinguishable from a network + /// fault. Never a secret — see [`OAuthManager::with_client_name`]. + pub client_name: String, } +/// The `client_name` devup-mcp sends to Dynamic Client Registration +/// unless the operator overrides it. +/// +/// Figma admits `POST /v1/oauth/mcp/register` only for `client_name` +/// values on its catalog allowlist and rejects everything else with a +/// plain-text `403 Forbidden`. devup-mcp itself is not on that +/// allowlist, so the literal name `devup-mcp` makes the `direct` path +/// unreachable. This default is therefore `Codex` — the host devup-mcp +/// is distributed to be installed into — so a Codex install can complete +/// `login` without extra flags. +/// +/// Two consequences to be aware of, neither of which devup-mcp can +/// resolve on its own: the value is sent verbatim as this client's +/// identity, so Figma attributes the registration and the resulting +/// traffic to Codex rather than to devup-mcp; and the allowlist is +/// Figma's access control, so this default routes around it. The +/// sanctioned path is admission through +/// , after which +/// [`OAuthManager::with_client_name`] should carry your own registered +/// name instead. +pub const DEFAULT_CLIENT_NAME: &str = "Codex"; + pub trait BrowserOpener: Send + Sync { fn open(&self, authorization_url: &str) -> Result<(), DevupError>; } @@ -78,7 +105,7 @@ impl BrowserOpener for SystemBrowser { webbrowser::open(authorization_url).map_err(|_| { DevupError::new( ErrorCode::DevupAuthRequired, - "브라우저를 열지 못했습니다. Figma 인증을 다시 시도하세요.", + "Could not open the browser. Retry Figma authentication.", true, ) })?; @@ -117,6 +144,10 @@ pub struct OAuthManager { /// is always `CliArg` or `Env`. static_client_credentials: Option<(ClientCredentials, ClientCredentialSource)>, client_credential_store: Arc, + /// The `client_name` sent to Dynamic Client Registration. Defaults to + /// [`DEFAULT_CLIENT_NAME`]; overridable per process because Figma + /// admits `/register` only for allowlisted names. + client_name: String, } impl OAuthManager { @@ -135,6 +166,7 @@ impl OAuthManager { callback_port: None, static_client_credentials: None, client_credential_store: Arc::new(MemoryClientCredentialStore::default()), + client_name: DEFAULT_CLIENT_NAME.to_owned(), } } @@ -152,6 +184,29 @@ impl OAuthManager { self } + /// Overrides the `client_name` sent to Dynamic Client Registration + /// (default [`DEFAULT_CLIENT_NAME`], i.e. `Codex`). + /// + /// Set this to the name your own client was admitted under through + /// ; doing so stops attributing + /// this client's registration and traffic to Codex, and is the only + /// configuration that does not depend on Figma's allowlist gate + /// staying permissive for a name that is not yours. + /// + /// The value is transmitted verbatim to the upstream authorization + /// server as this client's identity, so whichever name is active is + /// the identity Figma records. [`Self::direct_path_snapshot`] always + /// reports the value in play, and never a secret. + /// + /// An empty or whitespace-only name is ignored, keeping the default. + pub fn with_client_name(mut self, client_name: impl Into) -> Self { + let client_name = client_name.into(); + if !client_name.trim().is_empty() { + self.client_name = client_name; + } + self + } + /// Installs a cli-arg/env-supplied client credential override. This /// always takes priority over anything in `client_credential_store`, /// and causes `login` to skip Dynamic Client Registration entirely. @@ -249,6 +304,7 @@ impl OAuthManager { token_state, callback_port: self.callback_port, callback_port_free, + client_name: self.client_name.clone(), }) } @@ -265,9 +321,12 @@ impl OAuthManager { // A resolved client credential (cli-arg/env override or a // previously `configure`d value) always skips Dynamic Client - // Registration. Otherwise devup-mcp registers itself honestly as - // "devup-mcp" — never as another product's name — and Figma's - // allowlist decides the outcome (see `README.md`). + // Registration. Otherwise devup-mcp registers under + // `self.client_name` — `DEFAULT_CLIENT_NAME` (`Codex`) unless + // `--figma-client-name`/`DEVUP_FIGMA_CLIENT_NAME` supplied the + // name this deployment was actually admitted under — and Figma's + // allowlist decides the outcome. See `DEFAULT_CLIENT_NAME` for + // what that default does and does not license. let resolved = self.resolve_client_credentials().await?; let (client_id, client_secret) = match resolved { Some((credentials, _source)) => (credentials.client_id, credentials.client_secret), @@ -276,7 +335,7 @@ impl OAuthManager { .client .post(&metadata.registration_endpoint) .json(&serde_json::json!({ - "client_name": "devup-mcp", + "client_name": self.client_name.as_str(), "redirect_uris": [redirect_uri], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], @@ -298,7 +357,10 @@ impl OAuthManager { } let registration: RegistrationResponse = response.json().await.map_err(auth_network_error)?; - (registration.client_id, None) + ( + registration.client_id, + registration.client_secret.map(SecretString), + ) } }; @@ -346,6 +408,7 @@ impl OAuthManager { let authorization = StoredAuthorization { client_id, + client_secret, access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token @@ -385,10 +448,15 @@ impl OAuthManager { ("refresh_token", refresh_token.as_str()), ("resource", authorization.resource.as_str()), ]; - if let Some(secret) = resolved - .as_ref() - .and_then(|(credentials, _source)| credentials.client_secret.as_ref()) - { + // The secret that belongs to *this* authorization wins: when the + // client was registered through DCR the operator has no configured + // credential at all, and dropping it here would fail the refresh with + // the same bare 400 the initial exchange used to. + if let Some(secret) = authorization.client_secret.as_ref().or_else(|| { + resolved + .as_ref() + .and_then(|(credentials, _source)| credentials.client_secret.as_ref()) + }) { form.push(("client_secret", secret.expose())); } let response: TokenResponse = self @@ -496,6 +564,16 @@ struct OAuthMetadata { #[derive(Debug, Deserialize)] struct RegistrationResponse { client_id: String, + /// Figma's authorization server advertises only `client_secret_basic` + /// and `client_secret_post`, so its Dynamic Client Registration response + /// issues a secret and every subsequent token/refresh request must send + /// it. Discarding this field made the authorization-code exchange fail + /// with a bare `400` from `/v1/oauth/token` after an otherwise fully + /// successful registration and browser consent. `Option` because an + /// authorization server that genuinely supports public clients + /// (`token_endpoint_auth_method: none`) omits it. + #[serde(default)] + client_secret: Option, } #[derive(Debug, Deserialize)] @@ -520,7 +598,7 @@ async fn receive_callback( .map_err(|_| { DevupError::new( ErrorCode::DevupAuthCallbackTimeout, - "Figma 인증 응답 시간이 초과되었습니다.", + "Figma authentication response timed out.", true, ) })? @@ -548,7 +626,7 @@ async fn receive_callback( let _ = write_callback_response(&mut stream, false).await; return Err(DevupError::new( ErrorCode::DevupAuthStateMismatch, - "Figma 인증 state 검증에 실패했습니다.", + "Figma authentication state validation failed.", false, )); } @@ -562,9 +640,9 @@ async fn write_callback_response( success: bool, ) -> Result<(), DevupError> { let body = if success { - "Figma 인증이 완료되었습니다. 이 창을 닫아도 됩니다." + "Figma authentication is complete. You can close this window." } else { - "Figma 인증을 확인할 수 없습니다. 다시 시도하세요." + "Figma authentication could not be verified. Try again." }; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", @@ -606,11 +684,11 @@ fn callback_port_in_use_error(port: u16, _error: std::io::Error) -> DevupError { DevupError::with_details( ErrorCode::DevupFigmaCallbackPortInUse, format!( - "설정된 Figma 인증 콜백 포트 {port}을(를) 다른 프로세스가 이미 사용하고 있습니다. \ - OS나 보안 소프트웨어가 이 포트를 점유하고 있으면 브라우저는 리다이렉트에 성공한 \ - 것처럼 보이지만 요청이 devup-mcp가 아닌 다른 프로세스로 전달되어 인증이 끝나지 \ - 않습니다. 포트를 점유한 프로세스를 종료하거나 --figma-callback-port로 다른 포트를 \ - 지정하세요." + "The configured Figma auth callback port {port} is already in use by another \ + process. If the OS or security software holds this port, the browser looks like \ + the redirect succeeded, but the request is delivered to that other process instead \ + of devup-mcp, so authentication never completes. Stop the process holding the port, \ + or pick a different port with --figma-callback-port." ), false, serde_json::json!({ "port": port }), @@ -662,7 +740,7 @@ fn now() -> u64 { fn auth_required() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma 인증이 필요합니다.", + "Figma authentication is required.", false, ) } @@ -670,23 +748,63 @@ fn auth_required() -> DevupError { fn invalid_metadata() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma OAuth 서버 정보를 검증할 수 없습니다.", + "Cannot validate the Figma OAuth server metadata.", false, ) } -fn auth_network_error(_error: reqwest::Error) -> DevupError { - DevupError::new( +/// Classifies a transport failure against the Figma OAuth server. +/// +/// The cause used to be discarded outright, which made every failure — DNS, +/// a TLS trust failure behind a corporate proxy, a timeout, a malformed +/// metadata document — surface as the same opaque sentence with +/// `details: null`, leaving no way to tell them apart. The details below are +/// derived from the error itself and its source chain; the URL is reduced to +/// scheme/host/path so a query string can never carry an authorization code +/// or token into a log. +fn auth_network_error(error: reqwest::Error) -> DevupError { + let kind = if error.is_connect() { + "connect" + } else if error.is_timeout() { + "timeout" + } else if error.is_decode() { + "decode" + } else if error.is_status() { + "status" + } else if error.is_body() { + "body" + } else if error.is_redirect() { + "redirect" + } else if error.is_request() { + "request" + } else { + "unknown" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(&error); + while let Some(current) = source { + causes.push(current.to_string()); + source = current.source(); + } + DevupError::with_details( ErrorCode::DevupAuthRequired, - "Figma OAuth 서버와 통신하지 못했습니다.", + "Failed to communicate with the Figma OAuth server.", true, + serde_json::json!({ + "kind": kind, + "status": error.status().map(|status| status.as_u16()), + "url": error.url().map(|url| { + format!("{}://{}{}", url.scheme(), url.host_str().unwrap_or(""), url.path()) + }), + "causes": causes, + }), ) } fn callback_error(_error: std::io::Error) -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "로컬 Figma 인증 callback을 처리하지 못했습니다.", + "Failed to handle the local Figma authentication callback.", true, ) } diff --git a/crates/devup-mcp-figma/tests/oauth_flow.rs b/crates/devup-mcp-figma/tests/oauth_flow.rs index 0d7c47f..c7b9209 100644 --- a/crates/devup-mcp-figma/tests/oauth_flow.rs +++ b/crates/devup-mcp-figma/tests/oauth_flow.rs @@ -8,8 +8,8 @@ use axum::{ }; use devup_mcp_figma::{ AuthStatus, BrowserOpener, ClientCredentialSource, ClientCredentials, CredentialStore, - DirectPathSnapshot, ErrorCode, MemoryClientCredentialStore, MemoryCredentialStore, - OAuthManager, SecretString, TokenState, + DEFAULT_CLIENT_NAME, DirectPathSnapshot, ErrorCode, MemoryClientCredentialStore, + MemoryCredentialStore, OAuthManager, SecretString, TokenState, }; use serde_json::{Value, json}; use tokio::{net::TcpListener, sync::Mutex}; @@ -124,7 +124,7 @@ async fn login_discovers_registers_uses_pkce_and_stores_tokens() -> anyhow::Resu .await .clone() .expect("registration"); - assert_eq!(registration["client_name"], "devup-mcp"); + assert_eq!(registration["client_name"], DEFAULT_CLIENT_NAME); assert_eq!(registration["token_endpoint_auth_method"], "none"); assert!( registration["redirect_uris"][0] @@ -168,6 +168,79 @@ async fn logout_clears_persisted_authorization() -> anyhow::Result<()> { Ok(()) } +/// Mirrors Figma's real Dynamic Client Registration: its authorization +/// server advertises only `client_secret_basic`/`client_secret_post`, so +/// registration issues a confidential client with a secret. +async fn register_confidential( + State(state): State, + Json(body): Json, +) -> Json { + *state.captured.registration.lock().await = Some(body); + Json(json!({"client_id": "dynamic-client", "client_secret": "dynamic-secret"})) +} + +/// Regression: a secret issued by Dynamic Client Registration must reach the +/// authorization-code exchange. Discarding it made every real Figma login +/// fail with a bare `400` from `/v1/oauth/token` — after registration and +/// browser consent had both already succeeded, which made the failure look +/// like a network fault rather than a missing credential. +#[tokio::test] +async fn a_dcr_issued_client_secret_is_used_for_the_token_exchange_and_refresh() +-> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register_confidential)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store.clone()) + .with_callback_timeout(Duration::from_secs(3)); + let authorization = manager.login(&CallbackOpener).await?; + + let form = captured + .token_form + .lock() + .await + .clone() + .expect("token form"); + assert_eq!( + form.get("client_secret").map(String::as_str), + Some("dynamic-secret"), + "the DCR-issued secret must be sent to the token endpoint" + ); + + // It is kept with the authorization it belongs to, so a later refresh — + // which has no operator-configured credential to fall back on — can send + // it too. The secret must never surface in Debug output. + assert_eq!( + authorization + .client_secret + .as_ref() + .map(SecretString::expose), + Some("dynamic-secret") + ); + assert!(!format!("{authorization:?}").contains("dynamic-secret")); + + // Force the stored token to look expired so `access_token` refreshes. + let mut expired = CredentialStore::load(&store).await?.expect("authorization"); + expired.expires_at = Some(0); + CredentialStore::save(&store, &expired).await?; + manager.access_token().await?; + let refresh_form = captured + .token_form + .lock() + .await + .clone() + .expect("refresh form"); + assert_eq!( + refresh_form.get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + refresh_form.get("client_secret").map(String::as_str), + Some("dynamic-secret"), + "refresh must carry the DCR-issued secret as well" + ); + Ok(()) +} + async fn register_forbidden( State(state): State, Json(body): Json, @@ -251,9 +324,9 @@ async fn static_client_credentials_skip_dynamic_client_registration() -> anyhow: Ok(()) } -/// Core deliverable #3 (README honesty policy): with no client credential -/// resolvable, `login` still performs DCR with the literal, honest -/// `client_name: "devup-mcp"` (never impersonating another product), and a +/// Core deliverable #3: with no client credential resolvable and no +/// operator-supplied override, `login` performs DCR under +/// `DEFAULT_CLIENT_NAME`, and a /// 403 rejection (Figma's real response shape: plain-text `Forbidden`, not /// JSON) surfaces as a classified, actionable `DEVUP_FIGMA_CATALOG_REJECTED` /// error — not a generic network failure — carrying the four documented @@ -288,15 +361,80 @@ async fn dcr_403_is_classified_as_catalog_rejected_with_actionable_options() -> let serialized = serde_json::to_string(&error)?; assert!(!serialized.contains("Forbidden")); - // Never even attempted the DCR registration under a spoofed name; - // confirm the honest, literal request that *did* go out. + // Confirm the request that actually went out carried the compiled + // default, so a 403 here is attributable to the allowlist rather than + // to a stray per-process override. let registration = captured .registration .lock() .await .clone() .expect("registration attempt"); - assert_eq!(registration["client_name"], "devup-mcp"); + assert_eq!(registration["client_name"], DEFAULT_CLIENT_NAME); + Ok(()) +} + +/// The compiled default is a deployment decision, not an implementation +/// detail: devup-mcp is distributed to be installed into Codex, and the +/// literal name `devup-mcp` is not on Figma's catalog allowlist, so +/// defaulting to it would make `direct` unreachable out of the box. Pin +/// the value so flipping it is a deliberate, reviewed edit rather than a +/// silent drift — and pin that the override still wins over it. +#[test] +fn default_client_name_is_codex_and_remains_overridable() { + assert_eq!(DEFAULT_CLIENT_NAME, "Codex"); +} + +/// Figma admits `/register` only for `client_name` values on its catalog +/// allowlist, so an operator whose client was admitted under a different +/// name must be able to supply it at launch +/// (`--figma-client-name`/`DEVUP_FIGMA_CLIENT_NAME`) without a rebuild. +/// The override must reach the registration body verbatim — and only the +/// name changes: PKCE, redirect_uri and the token exchange are untouched. +#[tokio::test] +async fn configured_client_name_is_sent_verbatim_to_dynamic_client_registration() +-> anyhow::Result<()> { + let (base, captured) = spawn_mock_oauth_server(post(register)).await?; + + let store = MemoryCredentialStore::default(); + let manager = OAuthManager::with_endpoint(format!("{base}/mcp"), store) + .with_callback_timeout(Duration::from_secs(3)) + .with_client_name("Acme Registered Client"); + manager.login(&CallbackOpener).await?; + + let registration = captured + .registration + .lock() + .await + .clone() + .expect("registration attempt"); + assert_eq!(registration["client_name"], "Acme Registered Client"); + assert_eq!(registration["token_endpoint_auth_method"], "none"); + assert!( + registration["redirect_uris"][0] + .as_str() + .expect("redirect uri") + .starts_with("http://127.0.0.1:") + ); + + let snapshot = manager.direct_path_snapshot().await?; + assert_eq!(snapshot.client_name, "Acme Registered Client"); + Ok(()) +} + +/// A blank override is operator error (an unset env var expanding to an +/// empty string, say) and must never be sent as the client's identity — +/// it falls back to the honest default instead. +#[tokio::test] +async fn blank_client_name_override_falls_back_to_the_default() -> anyhow::Result<()> { + let manager = OAuthManager::with_endpoint( + "https://mcp.figma.com/mcp", + MemoryCredentialStore::default(), + ) + .with_client_name(" "); + + let snapshot = manager.direct_path_snapshot().await?; + assert_eq!(snapshot.client_name, DEFAULT_CLIENT_NAME); Ok(()) } @@ -438,6 +576,7 @@ fn client_secret_never_appears_in_debug_output() { token_state: TokenState::Valid, callback_port: Some(19876), callback_port_free: Some(true), + client_name: DEFAULT_CLIENT_NAME.to_owned(), }; let serialized = serde_json::to_string(&snapshot).expect("snapshot serializes"); assert!(!serialized.contains("super-secret-value")); diff --git a/crates/devup-mcp/src/lib.rs b/crates/devup-mcp/src/lib.rs index 1f3205e..3d8911f 100644 --- a/crates/devup-mcp/src/lib.rs +++ b/crates/devup-mcp/src/lib.rs @@ -16,6 +16,9 @@ pub struct ServerConfig { /// From `--figma-callback-port`. `None` preserves the pre-existing /// OS-assigned-port behavior. pub figma_callback_port: Option, + /// From `--figma-client-name`. `None` keeps devup-mcp's own literal + /// name for Dynamic Client Registration. + pub figma_client_name: Option, } /// Fully resolved Figma direct-connection configuration: cli-arg values @@ -29,6 +32,11 @@ pub struct FigmaDirectConfig { pub client_secret: Option, pub credential_source: ClientCredentialSource, pub callback_port: Option, + /// `client_name` for Dynamic Client Registration. `None` keeps + /// [`devup_mcp_figma::DEFAULT_CLIENT_NAME`]. Resolved independently of + /// the client-id/secret pair: a pre-registered credential skips DCR + /// entirely, so the two settings are never both in play. + pub client_name: Option, } /// Resolves the effective Figma direct-connection client credential from @@ -41,15 +49,22 @@ pub fn resolve_figma_direct_config( cli_client_id: Option, cli_client_secret: Option, cli_callback_port: Option, + cli_client_name: Option, env_client_id: Option, env_client_secret: Option, + env_client_name: Option, ) -> FigmaDirectConfig { + // Resolved independently of the credential pair below: a client name + // only matters on the Dynamic Client Registration path, which a + // pre-registered client_id skips outright. + let client_name = cli_client_name.or(env_client_name); if let Some(client_id) = cli_client_id { return FigmaDirectConfig { client_id: Some(client_id), client_secret: cli_client_secret, credential_source: ClientCredentialSource::CliArg, callback_port: cli_callback_port, + client_name, }; } if let Some(client_id) = env_client_id { @@ -58,24 +73,30 @@ pub fn resolve_figma_direct_config( client_secret: env_client_secret, credential_source: ClientCredentialSource::Env, callback_port: cli_callback_port, + client_name, }; } FigmaDirectConfig { callback_port: cli_callback_port, + client_name, ..FigmaDirectConfig::default() } } -/// Reads `DEVUP_FIGMA_CLIENT_ID`/`DEVUP_FIGMA_CLIENT_SECRET`, treating an -/// empty value the same as an unset one. -fn env_figma_client_credentials() -> (Option, Option) { - let client_id = std::env::var("DEVUP_FIGMA_CLIENT_ID") - .ok() - .filter(|value| !value.is_empty()); - let client_secret = std::env::var("DEVUP_FIGMA_CLIENT_SECRET") - .ok() - .filter(|value| !value.is_empty()); - (client_id, client_secret) +/// Reads `DEVUP_FIGMA_CLIENT_ID`/`DEVUP_FIGMA_CLIENT_SECRET`/ +/// `DEVUP_FIGMA_CLIENT_NAME`, treating an empty value the same as an +/// unset one. +fn env_figma_client_credentials() -> (Option, Option, Option) { + let read = |key: &str| { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) + }; + ( + read("DEVUP_FIGMA_CLIENT_ID"), + read("DEVUP_FIGMA_CLIENT_SECRET"), + read("DEVUP_FIGMA_CLIENT_NAME"), + ) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -110,11 +131,13 @@ where let mut figma_client_id: Option = None; let mut figma_client_secret: Option = None; let mut figma_callback_port: Option = None; + let mut figma_client_name: Option = None; while let Some(argument) = arguments.next() { let no_other_options_yet = roots.is_empty() && figma_client_id.is_none() && figma_client_secret.is_none() - && figma_callback_port.is_none(); + && figma_callback_port.is_none() + && figma_client_name.is_none(); match argument.to_str() { Some("--version" | "-V") if no_other_options_yet && arguments.peek().is_none() => { return Ok(CliAction::Version); @@ -124,57 +147,69 @@ where } Some("--allow-write-root") => { let root = arguments.next().ok_or_else(|| { - anyhow::anyhow!("--allow-write-root에는 폴더 경로가 필요합니다.") + anyhow::anyhow!("--allow-write-root requires a directory path.") })?; let root = PathBuf::from(root); if !root.is_dir() { - anyhow::bail!("--allow-write-root는 존재하는 폴더여야 합니다."); + anyhow::bail!("--allow-write-root must be an existing directory."); } roots.push(root); } Some("--figma-client-id") => { let value = arguments .next() - .ok_or_else(|| anyhow::anyhow!("--figma-client-id에는 값이 필요합니다."))?; + .ok_or_else(|| anyhow::anyhow!("--figma-client-id requires a value."))?; let value = value .to_str() - .ok_or_else(|| { - anyhow::anyhow!("--figma-client-id는 UTF-8 문자열이어야 합니다.") - })? + .ok_or_else(|| anyhow::anyhow!("--figma-client-id must be a UTF-8 string."))? .to_owned(); if value.is_empty() { - anyhow::bail!("--figma-client-id는 빈 문자열일 수 없습니다."); + anyhow::bail!("--figma-client-id must not be empty."); } figma_client_id = Some(value); } Some("--figma-client-secret") => { let value = arguments .next() - .ok_or_else(|| anyhow::anyhow!("--figma-client-secret에는 값이 필요합니다."))?; + .ok_or_else(|| anyhow::anyhow!("--figma-client-secret requires a value."))?; let value = value .to_str() .ok_or_else(|| { - anyhow::anyhow!("--figma-client-secret는 UTF-8 문자열이어야 합니다.") + anyhow::anyhow!("--figma-client-secret must be a UTF-8 string.") })? .to_owned(); if value.is_empty() { - anyhow::bail!("--figma-client-secret는 빈 문자열일 수 없습니다."); + anyhow::bail!("--figma-client-secret must not be empty."); } figma_client_secret = Some(value); } + Some("--figma-client-name") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-name requires a value."))?; + let value = value + .to_str() + .ok_or_else(|| anyhow::anyhow!("--figma-client-name must be a UTF-8 string."))? + .trim() + .to_owned(); + if value.is_empty() { + anyhow::bail!("--figma-client-name must not be empty."); + } + figma_client_name = Some(value); + } Some("--figma-callback-port") => { let value = arguments.next().ok_or_else(|| { - anyhow::anyhow!("--figma-callback-port에는 포트 번호가 필요합니다.") + anyhow::anyhow!("--figma-callback-port requires a port number.") })?; let value = value.to_str().ok_or_else(|| { - anyhow::anyhow!("--figma-callback-port는 UTF-8 문자열이어야 합니다.") + anyhow::anyhow!("--figma-callback-port must be a UTF-8 string.") })?; figma_callback_port = Some(value.parse::().map_err(|_| { - anyhow::anyhow!("--figma-callback-port는 1-65535 사이 숫자여야 합니다.") + anyhow::anyhow!("--figma-callback-port must be a number between 1 and 65535.") })?); } - Some(flag) => anyhow::bail!("지원하지 않는 devup-mcp 인자입니다: {flag}"), - None => anyhow::bail!("devup-mcp 인자는 UTF-8 flag여야 합니다."), + Some(flag) => anyhow::bail!("Unsupported devup-mcp argument: {flag}"), + None => anyhow::bail!("devup-mcp arguments must be UTF-8 flags."), } } if roots.is_empty() { @@ -185,14 +220,22 @@ where figma_client_id, figma_client_secret, figma_callback_port, + figma_client_name, })) } pub fn self_check() -> SelfCheckReport { let credential_ok = devup_mcp_figma::KeyringCredentialStore::probe().is_ok(); - let (env_client_id, env_client_secret) = env_figma_client_credentials(); - let figma_direct = - resolve_figma_direct_config(None, None, None, env_client_id, env_client_secret); + let (env_client_id, env_client_secret, env_client_name) = env_figma_client_credentials(); + let figma_direct = resolve_figma_direct_config( + None, + None, + None, + None, + env_client_id, + env_client_secret, + env_client_name, + ); let server_ok = std::env::current_dir() .ok() .and_then(|root| server::DevupServer::production_with_config(vec![root], figma_direct).ok()) @@ -221,13 +264,15 @@ pub async fn run_stdio() -> anyhow::Result<()> { pub async fn run_stdio_with_config(config: ServerConfig) -> anyhow::Result<()> { use rmcp::ServiceExt; - let (env_client_id, env_client_secret) = env_figma_client_credentials(); + let (env_client_id, env_client_secret, env_client_name) = env_figma_client_credentials(); let figma_direct = resolve_figma_direct_config( config.figma_client_id.clone(), config.figma_client_secret.clone(), config.figma_callback_port, + config.figma_client_name.clone(), env_client_id, env_client_secret, + env_client_name, ); let service = server::DevupServer::production_with_config(config.allowed_write_roots, figma_direct)? diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 7eccaef..018f240 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -29,7 +29,9 @@ use std::time::Duration; -use devup_mcp_figma::{AuthStatus, ClientCredentialSource, DirectPathSnapshot}; +use devup_mcp_figma::{ + AuthStatus, ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, +}; use serde_json::{Value, json}; /// Loopback address the Figma desktop app's local Dev Mode MCP server binds @@ -64,11 +66,11 @@ pub async fn local_dev_mode_reachable() -> bool { fn local_dev_mode_hint(reachable: bool) -> String { if reachable { format!( - "{LOCAL_DEV_MODE_ENDPOINT}가 응답하고 있습니다. 호스트에 이 로컬 Dev Mode MCP가 등록되어 있다면 OAuth 없이 그 도구를 바로 사용할 수 있습니다." + "{LOCAL_DEV_MODE_ENDPOINT} is responding. If the host has this local Dev Mode MCP registered, you can use its tools directly without OAuth." ) } else { format!( - "{LOCAL_DEV_MODE_ENDPOINT}가 응답하지 않습니다. Figma 데스크톱 앱 → Preferences → Dev Mode MCP 서버를 켜면 OAuth 없이 사용할 수 있습니다 (Dev 또는 Full 시트가 있는 유료 플랜 필요)." + "{LOCAL_DEV_MODE_ENDPOINT} is not responding. Enable Figma desktop app -> Preferences -> Dev Mode MCP server to use it without OAuth (requires a paid plan with a Dev or Full seat)." ) } } @@ -87,12 +89,12 @@ fn local_dev_mode_hint(reachable: bool) -> String { pub async fn host_requirement() -> Value { let reachable = local_dev_mode_reachable().await; json!({ - "reason": "devup-mcp는 Figma에 직접 접속하지 않습니다. 호스트에 등록된 공식 Figma MCP가 이 read-only 호출을 대신 실행해야 합니다.", + "reason": "devup-mcp does not connect to Figma directly. The official Figma MCP registered on the host must run this read-only call on its behalf.", "steps": [ - "이 세션에 등록된 공식 Figma MCP를 찾으세요. 흔한 이름: figma, figma-desktop, figma-local, figma-remote-mcp.", - "calls[].tool 이름의 도구를 calls[].arguments 그대로 호출하세요. arguments의 code 필드를 절대 수정하지 마세요.", - "받은 원본 결과를 가공 없이 devup_figma_continue { sessionId, callId, result } 로 넘기세요.", - "status가 needs_figma면 만료(expiresAt) 전까지 반복하세요." + "Find the official Figma MCP registered in this session. Common names: figma, figma-desktop, figma-local, figma-remote-mcp.", + "Call the tool named in calls[].tool with calls[].arguments exactly as given. Never modify the code field in arguments.", + "Pass the raw result through unchanged to devup_figma_continue { sessionId, callId, result }.", + "While status is needs_figma, repeat until expiresAt." ], "localDevMode": { "endpoint": LOCAL_DEV_MODE_ENDPOINT, @@ -101,18 +103,18 @@ pub async fn host_requirement() -> Value { }, "ifUnavailable": { "action": "stop-and-report", - "message": "Figma MCP에 접근할 수 없으면 즉시 멈추고 보고하세요. 디자인 수치를 추측해서 구현하지 마세요.", - "setupHint": "devup_figma_auth { action: \"doctor\" } 를 호출하면 사용 가능한 경로와 클라이언트별 설정 방법을 얻을 수 있습니다." + "message": "If no Figma MCP is reachable, stop immediately and report. Do not implement by guessing design values.", + "setupHint": "Call devup_figma_auth { action: \"doctor\" } to get the usable connection paths and client-specific setup instructions." }, "resultContract": { - "expects": "공식 Figma MCP CallToolResult 원본 전체 (가공 금지)", - "ifHostFlattensToText": "호스트가 텍스트만 준다면 { \"content\": [{ \"type\": \"text\", \"text\": <원문 그대로> }] } 로만 감싸라.", - "neverFabricate": "structuredContent 등 없는 필드를 지어내지 마라. 두 번 이상 형식 오류가 나면 추측을 멈추고 보고하라." + "expects": "The complete raw official Figma MCP CallToolResult (no processing)", + "ifHostFlattensToText": "If the host gives text only, wrap it as nothing more than { \"content\": [{ \"type\": \"text\", \"text\": }] }.", + "neverFabricate": "Do not invent fields you were not given, such as structuredContent. After two or more format errors, stop guessing and report." }, "outputExpectation": { - "whatYouWillGet": "이 핸드오프가 완주하면 devup-mcp가 devup-ui TSX를 생성해 반환한다.", - "doNotHandInterpret": "use_figma가 반환한 노드 트리(좌표·크기·계층)를 직접 해석해서 devup-ui 코드를 작성하지 마라. 좌표 계산으로 레이아웃을 추론하지 마라. 그것이 devup-mcp가 존재하는 이유다.", - "ifConversionFails": "stop-and-report. 노드 트리를 근거로 UI를 손으로 작성하는 것은 금지된 폴백이다." + "whatYouWillGet": "Once this handoff completes, devup-mcp generates and returns the devup-ui TSX.", + "doNotHandInterpret": "Do not hand-interpret the node tree (coordinates, sizes, hierarchy) use_figma returned to write devup-ui code. Do not infer layout from coordinate math. That is exactly why devup-mcp exists.", + "ifConversionFails": "stop-and-report. Hand-writing the UI from the node tree is a forbidden fallback." } }) } @@ -147,16 +149,21 @@ pub async fn doctor_report(status: AuthStatus, direct: DirectPathSnapshot) -> Va "port": direct.callback_port, "free": direct.callback_port_free }, + "registrationClientName": { + "value": direct.client_name, + "isDefault": direct.client_name == DEFAULT_CLIENT_NAME, + "note": "client_name Dynamic Client Registration will send. Figma matches it against its catalog allowlist exactly. The default is Codex, which the allowlist admits, so login works from a Codex install with no extra flags; Figma attributes that registration to Codex, not to devup-mcp. Once your own client is admitted through https://www.figma.com/mcp-catalog/, pass its name via --figma-client-name or DEVUP_FIGMA_CLIENT_NAME." + }, "reason": direct_reason(direct_available, direct.credential_source) }, "localDevMode": { "endpoint": LOCAL_DEV_MODE_ENDPOINT, "reachable": reachable, - "hint": "Figma 데스크톱 → Preferences → Dev Mode MCP 서버 활성화 (Dev/Full 시트 필요)" + "hint": "Figma desktop -> Preferences -> enable the Dev Mode MCP server (requires a Dev/Full seat)" }, "hostHandoff": { "expectedTool": "use_figma", - "note": "devup-mcp 내부에서는 확인 불가합니다. 호스트가 공식 Figma MCP를 노출해야 합니다." + "note": "Cannot be verified from inside devup-mcp. The host must expose the official Figma MCP." } }, "clientSetup": client_setup() @@ -173,21 +180,23 @@ fn direct_reason( credential_source: ClientCredentialSource, ) -> &'static str { if direct_available { - return "저장된 자격증명이 있습니다."; + return "A stored credential is present."; } match credential_source { ClientCredentialSource::None => { - "저장된 자격증명 없음. Figma는 allowlist된 client_name으로 등록한 client에만 \ - Dynamic Client Registration을 허용합니다. devup_figma_auth { action: \"configure\", \ - clientId, clientSecret }로 직접 확보한 client 자격증명을 등록하거나, Figma MCP \ - Catalog waitlist(https://www.figma.com/mcp-catalog/)에 등록하거나, 로컬 Dev Mode \ - MCP를 사용하거나, 호스트 핸드오프(sourcePolicy: auto 또는 host)를 사용하세요." + "No stored credential. Run devup_figma_auth { action: \"login\" }: with no \ + pre-registered credential it falls back to Dynamic Client Registration under the \ + default allowlisted client_name (see registrationClientName). If that returns 403, \ + the allowlist rejected the name — register a client credential you obtained yourself \ + via devup_figma_auth { action: \"configure\", clientId, clientSecret }, join the \ + Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/), use the local Dev \ + Mode MCP, or use the host handoff (sourcePolicy: auto or host)." } ClientCredentialSource::CliArg | ClientCredentialSource::Env | ClientCredentialSource::CredentialStore => { - "사전 등록된 client 자격증명이 있습니다. devup_figma_auth { action: \"login\" } 으로 \ - 인증하면 direct 경로를 사용할 수 있습니다." + "A pre-registered client credential is present. Authenticate with devup_figma_auth \ + { action: \"login\" } to use the direct path." } } } @@ -196,34 +205,46 @@ fn client_setup() -> Value { json!({ "constraints": { "registerEndpoint": "POST https://api.figma.com/v1/oauth/mcp/register", - "clientNameAllowlist": "Figma는 등록 요청의 client_name을 정확히 일치하는 allowlist로만 승인합니다(예: Codex, Claude Code는 200; OpenCode, opencode, Cursor, VS Code는 403). 승인되지 않은 이름은 JSON이 아닌 평문 'Forbidden' 본문과 함께 403을 반환하므로 여러 클라이언트의 OAuth 오류 파싱까지 함께 깨집니다. 신규 client 등록은 waitlist를 통해서만 가능합니다: https://www.figma.com/mcp-catalog/", - "redirectUri": "redirect_uri는 경로가 정확히 /callback이어야 하고 호스트는 127.0.0.1이어야 합니다(200). localhost 호스트나 /mcp/oauth/callback 같은 다른 경로는 400으로 거절됩니다.", - "callbackPortCaution": "OS나 보안 소프트웨어가 로컬 OAuth 콜백 포트를 이미 점유하고 있으면 브라우저는 리다이렉트에 성공한 것처럼 보이지만, 그 요청은 다른 프로세스로 전달되어 클라이언트는 에러 없이 'Waiting for authorization...' 상태로 무한 대기합니다. 콜백 포트를 다른 프로세스가 쓰고 있지 않은지 먼저 확인하세요.", - "personalAccessToken": "Figma PAT(figd_...)는 Authorization: Bearer, X-Figma-Token 어느 방식으로도 원격 MCP에서 지원되지 않습니다." + "clientNameAllowlist": "Figma approves a registration request's client_name only against an exact-match allowlist (e.g. Codex and Claude Code get 200; OpenCode, opencode, Cursor, and VS Code get 403). A non-approved name returns 403 with a plain-text 'Forbidden' body instead of JSON, which also breaks OAuth error parsing in several clients. Registering a new client is only possible through the waitlist: https://www.figma.com/mcp-catalog/", + "redirectUri": "redirect_uri must use exactly the path /callback and the host 127.0.0.1 (200). A localhost host, or another path such as /mcp/oauth/callback, is rejected with 400.", + "callbackPortCaution": "If the OS or security software already occupies the local OAuth callback port, the browser looks like it redirected successfully, but that request goes to the other process and the client waits forever at 'Waiting for authorization...' with no error. Check first that no other process is using the callback port.", + "personalAccessToken": "A Figma PAT (figd_...) is not supported by the remote MCP through either Authorization: Bearer or X-Figma-Token." + }, + "codex": { + "primary": true, + "hint": "The intended host. devup-mcp registers under client_name Codex by default, so devup_figma_auth { action: \"login\" } completes from a Codex install with no extra flags and no client_id/client_secret. Add --figma-client-name only once your own client is admitted to the Figma MCP catalog.", + "installDevupMcp": { + "file": "~/.codex/config.toml", + "toml": "[mcp_servers.devup-mcp]\ncommand = \"devup-mcp\"\nargs = [\"--allow-write-root\", \"\"]", + "then": "Restart Codex, then call devup_figma_auth { action: \"login\" } once to store the token." + }, + "officialFigmaMcp": "codex mcp add figma --url https://mcp.figma.com/mcp" }, - "opencode": { - "hint": "mcp..oauth에 clientId/clientSecret/scope/callbackPort/redirectUri를 직접 지정하면 Dynamic Client Registration을 건너뜁니다. clientId/clientSecret은 allowlist된 client_name으로 직접 등록해 발급받아야 합니다.", - "example": { - "mcp": { - "figma": { - "type": "remote", - "url": "https://mcp.figma.com/mcp", - "oauth": { - "clientId": "", - "clientSecret": "", - "scope": "mcp:connect", - "callbackPort": 19876, - "redirectUri": "http://127.0.0.1:19876/callback" + "otherHosts": { + "note": "Reference only — devup-mcp targets Codex. Kept for the host-handoff path (sourcePolicy: auto or host) when devup-mcp runs elsewhere.", + "claudeCode": "claude mcp add --transport http figma https://mcp.figma.com/mcp", + "opencode": { + "hint": "Setting clientId/clientSecret/scope/callbackPort/redirectUri directly under mcp..oauth skips Dynamic Client Registration. clientId/clientSecret must be issued to you by registering yourself under an allowlisted client_name.", + "example": { + "mcp": { + "figma": { + "type": "remote", + "url": "https://mcp.figma.com/mcp", + "oauth": { + "clientId": "", + "clientSecret": "", + "scope": "mcp:connect", + "callbackPort": 19876, + "redirectUri": "http://127.0.0.1:19876/callback" + } } } } } }, - "claudeCode": "claude mcp add --transport http figma https://mcp.figma.com/mcp", - "codex": "codex mcp add figma --url https://mcp.figma.com/mcp", "localDevMode": { "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "hint": "OAuth가 필요 없습니다. Figma 데스크톱 앱에서 Dev Mode MCP 서버를 켜면 어떤 MCP 클라이언트에서도 동일하게 동작합니다. Dev 또는 Full 시트가 있는 유료 플랜이 필요합니다." + "hint": "No OAuth needed. Turning on the Dev Mode MCP server in the Figma desktop app behaves identically from any MCP client. Requires a paid plan with a Dev or Full seat." } }) } @@ -299,11 +320,11 @@ mod tests { let do_not_hand_interpret = value["outputExpectation"]["doNotHandInterpret"] .as_str() .unwrap(); - assert!(do_not_hand_interpret.contains("노드 트리")); + assert!(do_not_hand_interpret.contains("node tree")); assert!(do_not_hand_interpret.contains("devup-ui")); assert_eq!( value["outputExpectation"]["ifConversionFails"], - "stop-and-report. 노드 트리를 근거로 UI를 손으로 작성하는 것은 금지된 폴백이다." + "stop-and-report. Hand-writing the UI from the node tree is a forbidden fallback." ); } @@ -313,6 +334,7 @@ mod tests { token_state: devup_mcp_figma::TokenState::Absent, callback_port: None, callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), } } @@ -334,7 +356,55 @@ mod tests { "use_figma" ); assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); - assert!(disconnected["clientSetup"]["opencode"]["example"].is_object()); + assert!(disconnected["clientSetup"]["otherHosts"]["opencode"]["example"].is_object()); + } + + /// Codex is the host devup-mcp is installed into, so `clientSetup` + /// must lead with a self-contained Codex install path — the other + /// hosts stay available but demoted, so they cannot be mistaken for + /// the primary route. + #[tokio::test] + async fn client_setup_leads_with_codex_and_demotes_the_other_hosts() { + let report = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; + let setup = &report["clientSetup"]; + + assert_eq!(setup["codex"]["primary"], true); + let toml = setup["codex"]["installDevupMcp"]["toml"] + .as_str() + .expect("codex install snippet"); + assert!(toml.contains("[mcp_servers.devup-mcp]")); + assert!(setup["codex"]["hint"].as_str().unwrap().contains("Codex")); + + // Demoted, not deleted: still reachable for the host-handoff path. + assert!(setup["otherHosts"]["claudeCode"].is_string()); + assert!(setup["otherHosts"]["opencode"]["example"].is_object()); + assert!(setup["claudeCode"].is_null()); + assert!(setup["opencode"].is_null()); + } + + /// The `client_name` DCR will actually send is the single fact that + /// decides whether `/register` returns 200 or a plain-text 403, so + /// `doctor` must report it — and must say plainly when it is still the + /// (non-allowlisted) default rather than an operator-supplied name. + #[tokio::test] + async fn doctor_report_surfaces_the_registration_client_name_and_whether_it_is_default() { + let default_report = + doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; + let default_name = &default_report["paths"]["direct"]["registrationClientName"]; + assert_eq!(default_name["value"], DEFAULT_CLIENT_NAME); + assert_eq!(default_name["isDefault"], true); + + let overridden = doctor_report( + AuthStatus::Disconnected, + DirectPathSnapshot { + client_name: "Acme Registered Client".to_owned(), + ..absent_direct_snapshot() + }, + ) + .await; + let overridden_name = &overridden["paths"]["direct"]["registrationClientName"]; + assert_eq!(overridden_name["value"], "Acme Registered Client"); + assert_eq!(overridden_name["isDefault"], false); } #[tokio::test] @@ -344,6 +414,7 @@ mod tests { token_state: devup_mcp_figma::TokenState::Expired, callback_port: Some(19876), callback_port_free: Some(false), + client_name: DEFAULT_CLIENT_NAME.to_owned(), }; let report = doctor_report(AuthStatus::Disconnected, snapshot).await; assert_eq!(report["paths"]["direct"]["credentialSource"], "cli-arg"); @@ -375,6 +446,7 @@ mod tests { token_state: devup_mcp_figma::TokenState::Valid, callback_port: Some(19876), callback_port_free: Some(true), + client_name: DEFAULT_CLIENT_NAME.to_owned(), }; let report = doctor_report(AuthStatus::Connected, snapshot).await; assert!(report["paths"]["direct"].get("clientSecret").is_none()); diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index d0489b8..ee04930 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -31,8 +31,8 @@ use devup_mcp_devup_ui::theme::ThemeScope; use devup_mcp_figma::{ AuthStatus, ClientCredentialSource, ClientCredentials, CollectedParts, CollectedPayload, CollectionRequest, CollectionScope, CollectorSession, CollectorStep, CredentialStore, - DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, ExploreNode, - ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, + DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, + ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, KeyringCredentialStore, OAuthManager, RemoteFigmaClient, ResourceScope, SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, SourcePolicy, SystemBrowser, TokenState, fallback_allowed_for_error, @@ -78,6 +78,7 @@ pub trait DevupAuth: Send + Sync { }, callback_port: None, callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), }) } @@ -91,7 +92,7 @@ pub trait DevupAuth: Send + Sync { ) -> Result<(), DevupError> { Err(DevupError::new( ErrorCode::DevupAuthRequired, - "이 auth 백엔드는 client 자격증명 설정을 지원하지 않습니다.", + "This auth backend does not support configuring client credentials.", false, )) } @@ -143,6 +144,9 @@ impl Services { if figma_direct.callback_port.is_some() { oauth = oauth.with_callback_port(figma_direct.callback_port); } + if let Some(client_name) = figma_direct.client_name { + oauth = oauth.with_client_name(client_name); + } if let Some(client_id) = figma_direct.client_id { oauth = oauth.with_static_client_credentials( ClientCredentials { @@ -252,7 +256,7 @@ impl DevupServer { } return Err(DevupError::with_details( ErrorCode::DevupAuthRequired, - "Figma direct 연결을 사용하려면 devup_figma_auth login이 필요합니다.", + "Using the Figma direct connection requires devup_figma_auth login.", false, json!({"source": "direct"}), )); @@ -350,7 +354,7 @@ impl DevupServer { else { return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "Figma handoff artifact key가 없습니다.", + "The Figma handoff artifact key is missing.", false, )); }; @@ -414,7 +418,7 @@ impl DevupServer { let client_id = input.client_id.ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupInvalidInput, - "configure에는 clientId가 필요합니다.", + "configure requires clientId.", false, )) })?; @@ -432,7 +436,7 @@ impl DevupServer { _ => { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupAuthRequired, - "action은 status, login, logout, configure 또는 doctor여야 합니다.", + "action must be status, login, logout, configure, or doctor.", false, ))); } @@ -453,7 +457,7 @@ impl DevupServer { target.node_id.as_ref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "UI 변환 링크에는 node-id가 필요합니다.", + "A UI conversion link requires a node-id.", false, )) })?; @@ -570,14 +574,14 @@ impl DevupServer { target.node_id.as_ref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "Figma 주변 화면 탐색에는 node-id가 필요합니다.", + "Exploring neighboring Figma screens requires a node-id.", false, )) })?; if !(1..=100).contains(&input.limit) { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaResponseTooLarge, - "탐색 limit은 1 이상 100 이하여야 합니다.", + "The explore limit must be between 1 and 100 inclusive.", false, ))); } @@ -642,7 +646,7 @@ impl DevupServer { { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "assetRequests를 사용하려면 outputs에 assetManifest가 필요합니다.", + "Using assetRequests requires assetManifest in outputs.", false, ))); } @@ -650,7 +654,7 @@ impl DevupServer { if reference_png_requested && (!input.frame_ids.is_empty() || input.all_screens) { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "referencePng는 단일 Figma 링크 대상에서만 수집할 수 있습니다.", + "referencePng can only be collected for a single Figma link target.", false, ))); } @@ -665,14 +669,14 @@ impl DevupServer { if input.url.is_some() || input.refresh { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "artifactId는 url 또는 refresh와 함께 사용할 수 없습니다.", + "artifactId cannot be used together with url or refresh.", false, ))); } let artifact = self.artifacts.get(artifact_id).await.ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffExpired, - "Figma artifact가 없거나 만료되었습니다.", + "The Figma artifact is missing or expired.", true, )) })?; @@ -684,7 +688,7 @@ impl DevupServer { let index = section_index_from_payload(&artifact.payload).ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "Section index artifact payload가 올바르지 않습니다.", + "The Section index artifact payload is invalid.", false, )) })?; @@ -694,7 +698,7 @@ impl DevupServer { if collection_scope != CollectionScope::Node { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Section Frame 수집 scope는 node여야 합니다.", + "The Section Frame collection scope must be node.", false, ))); } @@ -768,7 +772,7 @@ impl DevupServer { let url = input.url.as_deref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "url 또는 artifactId 중 하나가 필요합니다.", + "Either url or artifactId is required.", false, )) })?; @@ -776,7 +780,7 @@ impl DevupServer { if input.outputs.iter().any(|output| output == "tsx") && target.node_id.is_none() { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, - "TSX export 링크에는 node-id가 필요합니다.", + "A TSX export link requires a node-id.", false, ))); } @@ -941,7 +945,7 @@ fn parse_scope(scope: &str) -> Result { "file" => Ok(ThemeScope::File), _ => Err(DevupError::new( ErrorCode::DevupThemeConflict, - "scope는 node, page 또는 file이어야 합니다.", + "scope must be node, page, or file.", false, )), } @@ -966,14 +970,14 @@ impl ServerHandler for DevupServer { ) .with_server_info(Implementation::new("devup-mcp", env!("CARGO_PKG_VERSION"))) .with_instructions( - "1. Figma 디자인을 코드로 옮기는 작업에서 devup-mcp가 1차 소스다. 다른 출처로 이를 대체하지 마라.\n\ - 2. 구현이 목적이면 devup_figma_export를 먼저 호출하고 tsx, rawSnapshot, sourceMap을 함께 받아라.\n\ - 3. get_design_context, 스크린샷, 시각적 추론은 검증 보조일 뿐이다. devup-mcp 출력을 덮어쓰지 마라.\n\ - 4. 핸드오프로 받은 노드 트리를 직접 해석해 devup-ui 코드를 작성하지 마라. 좌표로 레이아웃을 추론하지 마라.\n\ - 5. 핸드오프 단계에서는 요청된 도구를 요청된 arguments 그대로 실행하고, 원본 결과를 가공 없이 devup_figma_continue로 돌려줘라.\n\ - 6. devup-mcp 호출이 실패하면 명시적으로 기록하라. 조용히 다른 방법으로 우회하지 마라.\n\ - 7. 색상·간격·radius·타이포 같은 UI 수치를 추측하지 마라. 확보하지 못했으면 멈추고 보고하라.\n\ - 8. Section 링크는 전체 subtree로 구현하지 마라. selection_required의 후보를 확인하고 frameIds 또는 allScreens로 화면별 export를 계속하라.", + "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n\ + 2. When the goal is implementation, call devup_figma_export first and take tsx, rawSnapshot, and sourceMap together.\n\ + 3. get_design_context, screenshots, and visual reasoning are verification aids only. Do not overwrite devup-mcp output.\n\ + 4. Do not hand-interpret a node tree received from a handoff to write devup-ui code. Do not infer layout from coordinates.\n\ + 5. In a handoff step, run the requested tool with the requested arguments exactly, and return the raw result unchanged via devup_figma_continue.\n\ + 6. If a devup-mcp call fails, record it explicitly. Do not silently route around it.\n\ + 7. Do not guess UI values such as color, spacing, radius, or typography. If you could not obtain them, stop and report.\n\ + 8. Do not implement a Section link as one whole subtree. Check the selection_required candidates and continue with per-screen export via frameIds or allScreens.", ) } diff --git a/crates/devup-mcp/tests/cli.rs b/crates/devup-mcp/tests/cli.rs index 4b50105..6fa09cb 100644 --- a/crates/devup-mcp/tests/cli.rs +++ b/crates/devup-mcp/tests/cli.rs @@ -151,6 +151,7 @@ fn no_arguments_use_the_startup_current_directory() -> anyhow::Result<()> { assert_eq!(config.figma_client_id, None); assert_eq!(config.figma_client_secret, None); assert_eq!(config.figma_callback_port, None); + assert_eq!(config.figma_client_name, None); Ok(()) } @@ -207,6 +208,31 @@ fn figma_client_id_and_secret_reject_missing_or_empty_values() { assert!(parse_cli_args([OsString::from("--figma-client-secret"), OsString::from("")]).is_err()); } +/// The DCR `client_name` is what Figma's catalog allowlist is matched +/// against, so it is configurable at launch. It is trimmed, and a blank +/// value is an error rather than a silently-sent empty identity. +#[test] +fn figma_client_name_flag_populates_server_config_and_rejects_blank_values() -> anyhow::Result<()> { + let action = parse_cli_args([ + OsString::from("--figma-client-name"), + OsString::from(" Acme Registered Client "), + ])?; + let CliAction::Serve(config) = action else { + panic!("--figma-client-name must start the server") + }; + assert_eq!( + config.figma_client_name.as_deref(), + Some("Acme Registered Client") + ); + + assert!(parse_cli_args([OsString::from("--figma-client-name")]).is_err()); + assert!(parse_cli_args([OsString::from("--figma-client-name"), OsString::from("")]).is_err()); + assert!( + parse_cli_args([OsString::from("--figma-client-name"), OsString::from(" ")]).is_err() + ); + Ok(()) +} + #[test] fn version_and_self_check_are_rejected_when_combined_with_figma_flags() { // `--version`/`--self-check` must only win when they are the *sole* @@ -237,13 +263,16 @@ fn resolve_figma_direct_config_prioritizes_cli_arg_over_env() { Some("cli-client".to_owned()), Some("cli-secret".to_owned()), Some(19876), + Some("Cli Client Name".to_owned()), Some("env-client".to_owned()), Some("env-secret".to_owned()), + Some("Env Client Name".to_owned()), ); assert_eq!(resolved.client_id.as_deref(), Some("cli-client")); assert_eq!(resolved.client_secret.as_deref(), Some("cli-secret")); assert_eq!(resolved.credential_source, ClientCredentialSource::CliArg); assert_eq!(resolved.callback_port, Some(19876)); + assert_eq!(resolved.client_name.as_deref(), Some("Cli Client Name")); } #[test] @@ -252,23 +281,74 @@ fn resolve_figma_direct_config_falls_back_to_env_then_to_none() { None, None, None, + None, Some("env-client".to_owned()), Some("env-secret".to_owned()), + Some("Env Client Name".to_owned()), ); assert_eq!(env_only.client_id.as_deref(), Some("env-client")); assert_eq!(env_only.credential_source, ClientCredentialSource::Env); + assert_eq!(env_only.client_name.as_deref(), Some("Env Client Name")); - let neither = resolve_figma_direct_config(None, None, None, None, None); + let neither = resolve_figma_direct_config(None, None, None, None, None, None, None); assert_eq!(neither.client_id, None); assert_eq!(neither.client_secret, None); assert_eq!(neither.credential_source, ClientCredentialSource::None); + assert_eq!(neither.client_name, None); // Callback port is independent of credential source: it always comes // from the cli-arg value regardless of which credential source won. - let callback_port_only = resolve_figma_direct_config(None, None, Some(19876), None, None); + let callback_port_only = + resolve_figma_direct_config(None, None, Some(19876), None, None, None, None); assert_eq!(callback_port_only.callback_port, Some(19876)); assert_eq!( callback_port_only.credential_source, ClientCredentialSource::None ); } + +/// The client name lives on the Dynamic Client Registration path, which a +/// pre-registered `client_id` skips outright — so it must resolve +/// independently of the credential pair, and be available even when no +/// credential is configured at all (exactly the case where DCR runs). +#[test] +fn resolve_figma_direct_config_resolves_client_name_independently_of_credentials() { + let name_without_credentials = resolve_figma_direct_config( + None, + None, + None, + Some("Acme Registered Client".to_owned()), + None, + None, + None, + ); + assert_eq!( + name_without_credentials.client_name.as_deref(), + Some("Acme Registered Client") + ); + assert_eq!(name_without_credentials.client_id, None); + assert_eq!( + name_without_credentials.credential_source, + ClientCredentialSource::None + ); + + // No cli-arg name: the env value carries even when the winning + // credential source is the cli arg. + let env_name_with_cli_credentials = resolve_figma_direct_config( + Some("cli-client".to_owned()), + None, + None, + None, + None, + None, + Some("Env Client Name".to_owned()), + ); + assert_eq!( + env_name_with_cli_credentials.client_name.as_deref(), + Some("Env Client Name") + ); + assert_eq!( + env_name_with_cli_credentials.credential_source, + ClientCredentialSource::CliArg + ); +} diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index b551bea..911ed14 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -12,8 +12,8 @@ use std::sync::{ use async_trait::async_trait; use devup_mcp::server::{DevupAuth, DevupServer, Services}; use devup_mcp_figma::{ - AuthStatus, ClientCredentialSource, DevupError, DirectPathSnapshot, ErrorCode, FigmaUpstream, - ReadToolCall, TokenState, UpstreamResult, + AuthStatus, ClientCredentialSource, DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, + ErrorCode, FigmaUpstream, ReadToolCall, TokenState, UpstreamResult, }; use rmcp::{ ServiceExt, @@ -151,14 +151,28 @@ async fn doctor_action_reports_measured_paths_and_client_setup_data() -> anyhow: assert!(client_setup["constraints"]["redirectUri"].is_string()); assert!(client_setup["constraints"]["callbackPortCaution"].is_string()); assert!(client_setup["constraints"]["personalAccessToken"].is_string()); - assert!(client_setup["opencode"]["example"]["mcp"]["figma"]["oauth"].is_object()); + // Codex is the primary, self-contained install path; the other hosts + // remain reachable but demoted under `otherHosts`. + assert_eq!(client_setup["codex"]["primary"], true); assert!( - client_setup["claudeCode"] + client_setup["codex"]["installDevupMcp"]["toml"] + .as_str() + .unwrap() + .contains("[mcp_servers.devup-mcp]") + ); + assert!( + client_setup["codex"]["officialFigmaMcp"] + .as_str() + .unwrap() + .contains("figma") + ); + assert!(client_setup["otherHosts"]["opencode"]["example"]["mcp"]["figma"]["oauth"].is_object()); + assert!( + client_setup["otherHosts"]["claudeCode"] .as_str() .unwrap() .contains("figma") ); - assert!(client_setup["codex"].as_str().unwrap().contains("figma")); assert_eq!( client_setup["localDevMode"]["endpoint"], "http://127.0.0.1:3845/mcp" @@ -240,7 +254,7 @@ async fn needs_figma_always_carries_an_actionable_host_requirement() -> anyhow:: host_requirement["ifUnavailable"]["message"] .as_str() .unwrap() - .contains("추측") + .contains("guessing") ); assert!( host_requirement["ifUnavailable"]["setupHint"] @@ -303,7 +317,7 @@ async fn needs_figma_always_carries_result_contract_and_output_expectation() -> .contains("devup-ui") ); let do_not_hand_interpret = output_expectation["doNotHandInterpret"].as_str().unwrap(); - assert!(do_not_hand_interpret.contains("노드 트리")); + assert!(do_not_hand_interpret.contains("node tree")); assert!(do_not_hand_interpret.contains("devup-ui")); assert!( output_expectation["ifConversionFails"] @@ -405,6 +419,7 @@ async fn doctor_reports_measured_credential_source_token_state_and_callback_port token_state: TokenState::Expired, callback_port: Some(19876), callback_port_free: Some(false), + client_name: DEFAULT_CLIENT_NAME.to_owned(), }, configured: Mutex::new(None), }; @@ -438,6 +453,7 @@ async fn configure_action_persists_credentials_and_never_echoes_the_secret() -> token_state: TokenState::Absent, callback_port: None, callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), }, configured: Mutex::new(None), }); From 6696f2dda290181446f2cbea26b44039d885d0d7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:00:40 +0900 Subject: [PATCH 22/69] fix(figma): tolerate a re-serializing relay when decoding upstream results get_metadata is no longer bare XML: Figma prepends a `Currently selected nodes:` block whenever the queried node is selected, and appends an instruction footer. Requiring the text to start with '<' made the whole legacy metadata path fail with `metadata not found` in that very common case; the XML region is now sliced out instead. The fast envelope required integrity.utf8Bytes to equal the received byte length, so it had to arrive byte-for-byte identical. No relay that re-serializes JSON can guarantee that. Truncation and corruption are already caught by JSON parsing plus the nodeCount / resourceRefCount / validate_resources checks, which read the content rather than its serialized form, so the byte comparison only produced false negatives. The decoder ceiling is raised to 64 KiB for the same reason, still bounded. --- crates/devup-mcp-figma/src/envelope.rs | 34 ++++++--- crates/devup-mcp-figma/src/metadata.rs | 88 ++++++++++++++++++++++-- crates/devup-mcp-figma/tests/envelope.rs | 57 +++++++++++---- 3 files changed, 152 insertions(+), 27 deletions(-) diff --git a/crates/devup-mcp-figma/src/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index c264e98..7ede995 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -8,7 +8,13 @@ use crate::{ collect_used_resource_refs, read_snapshot_cursor, }; -const MAX_TEXT_ENVELOPE_BYTES: usize = 15 * 1024; +/// Decoder-side ceiling on a single text envelope. Deliberately larger than +/// the 15 KiB the producing script budgets itself to: a relay that +/// re-serializes the JSON (pretty-printing, different escaping) inflates the +/// payload without changing its content, and rejecting that as `too_large` +/// would fail a perfectly valid envelope. Still bounded, so a hostile or +/// runaway response cannot be buffered without limit. +const MAX_TEXT_ENVELOPE_BYTES: usize = 64 * 1024; const MAX_STRINGIFIED_RESULT_BYTES: usize = 16 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -285,9 +291,17 @@ fn validate_envelope( { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes { - return Err(invalid("utf8Bytes")); - } + // `integrity.utf8Bytes` is deliberately NOT compared against the received + // byte length. It is the producer's self-measurement, so requiring exact + // equality meant the envelope had to arrive byte-for-byte identical — + // which no relay that re-serializes JSON can guarantee, and an MCP host + // that hands the result to an agent to pass on is exactly such a relay. + // Nothing is lost: truncation or corruption cannot slip past the checks + // below (a truncated JSON document fails to parse at all, and nodeCount / + // resourceRefCount / validate_resources are computed from the content + // rather than from its serialized form). It stays on the wire, and in + // `FastTransportStats`, purely as a reported size. + let _ = utf8_bytes; let mut node_ids = BTreeSet::new(); for node in &envelope.snapshot.nodes { @@ -346,9 +360,11 @@ fn validate_theme_envelope( if envelope.source.file_key != expected_file_key { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes { - return Err(invalid("utf8Bytes")); - } + // Not compared against the received length, for the same reason as + // `validate_envelope`: the collection/variable/style/unresolved counts + // below verify the content itself, and demanding a byte-exact match only + // broke relays that re-serialize JSON. + let _ = utf8_bytes; let resources = envelope .resources .as_object() @@ -476,7 +492,7 @@ fn resource_ids<'a>( fn invalid(category: &'static str) -> DevupError { DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, - "Figma fast snapshot envelope 검증에 실패했습니다.", + "Figma fast snapshot envelope validation failed.", false, json!({"category": category}), ) @@ -485,7 +501,7 @@ fn invalid(category: &'static str) -> DevupError { fn too_large(category: &'static str) -> DevupError { DevupError::with_details( ErrorCode::DevupFigmaResponseTooLarge, - "Figma fast snapshot envelope가 안전한 크기 제한을 초과했습니다.", + "Figma fast snapshot envelope exceeded the safe size limit.", false, json!({"category": category}), ) diff --git a/crates/devup-mcp-figma/src/metadata.rs b/crates/devup-mcp-figma/src/metadata.rs index 5072ffb..69c6b3b 100644 --- a/crates/devup-mcp-figma/src/metadata.rs +++ b/crates/devup-mcp-figma/src/metadata.rs @@ -56,7 +56,7 @@ pub fn metadata_from_result_for_target( .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "Figma MCP 응답에서 metadata를 찾지 못했습니다.", + "metadata not found in the Figma MCP response.", false, ) }) @@ -122,13 +122,33 @@ fn find_xml_metadata( Value::Array(values) => values .iter() .find_map(|value| find_xml_metadata(value, expected_file_key, expected_root_id)), - Value::String(text) if text.trim_start().starts_with('<') => { - parse_xml_metadata(text, expected_file_key, expected_root_id) - } + Value::String(text) => xml_slice(text) + .and_then(|xml| parse_xml_metadata(xml, expected_file_key, expected_root_id)), _ => None, } } +/// Extracts the XML region from a `get_metadata` text response. +/// +/// Figma no longer returns bare XML. When the user has the queried node +/// selected in the desktop app, the response is *prepended* with a +/// `Currently selected nodes:` block, and every response is *appended* with +/// an `IMPORTANT: After you call this tool...` instruction footer. Requiring +/// the text to start with `<` therefore made devup-mcp fail with +/// `metadata not found in the Figma MCP response.` for the very common case +/// of "the user is looking at the node they asked about". +/// +/// Slicing between the first `<` and the last `>` keeps the pre-existing +/// bare-XML input working unchanged, tolerates prose on either side, and +/// still yields `None` for text that carries no element at all. Text that +/// merely *contains* angle brackets is not a risk: `parse_xml_metadata` +/// returns `None` unless it finds at least one element with an `id`. +fn xml_slice(text: &str) -> Option<&str> { + let start = text.find('<')?; + let end = text.rfind('>')?; + (end > start).then(|| &text[start..=end]) +} + fn parse_xml_metadata( text: &str, expected_file_key: &str, @@ -236,6 +256,66 @@ fn descendant_count(index: usize, nodes: &[XmlNode]) -> usize { .sum::() } +#[cfg(test)] +mod tests { + use super::*; + + const XML: &str = "\n \ + \n"; + + fn parse(text: &str) -> Option { + find_xml_metadata( + &Value::String(text.to_owned()), + "85CgSws3o5XsLv7aAwWJyS", + Some("3997:48764"), + ) + } + + #[test] + fn bare_xml_still_parses() { + let document = parse(XML).expect("bare XML"); + assert_eq!(document.root_id, "3997:48764"); + assert_eq!(document.nodes.len(), 2); + } + + /// The regression this fix exists for: with the node selected in the + /// Figma desktop app, `get_metadata` prepends a selection block, which + /// used to make the whole legacy metadata path fail. + #[test] + fn a_selected_nodes_preamble_is_tolerated() { + let text = format!("Currently selected nodes:\n- 3997:48764: A : STORY-INTRO\n\n\n\n{XML}"); + let document = parse(&text).expect("preamble must not break parsing"); + assert_eq!(document.root_id, "3997:48764"); + assert_eq!(document.nodes.len(), 2); + } + + #[test] + fn an_instruction_footer_is_tolerated() { + let text = format!( + "{XML}\n\nIMPORTANT: After you call this tool, you MUST call get_design_context \ + if trying to implement the design." + ); + assert_eq!(parse(&text).expect("footer").root_id, "3997:48764"); + } + + #[test] + fn a_preamble_and_a_footer_together_are_tolerated() { + let text = + format!("Currently selected nodes:\n- 3997:48764: A\n\n{XML}\n\nIMPORTANT: do X."); + let document = parse(&text).expect("preamble and footer"); + assert_eq!(document.root_id, "3997:48764"); + assert_eq!(document.nodes.len(), 2); + } + + #[test] + fn prose_without_any_element_is_still_rejected() { + assert!(parse("Currently selected nodes:\n- 3997:48764: A : STORY-INTRO").is_none()); + assert!(parse("no angle brackets here at all").is_none()); + // Angle brackets but no element carrying an `id`. + assert!(parse("a < b and c > d").is_none()); + } +} + fn find_metadata(value: &Value) -> Option { if let Ok(document) = serde_json::from_value::(value.clone()) && !document.file_key.is_empty() diff --git a/crates/devup-mcp-figma/tests/envelope.rs b/crates/devup-mcp-figma/tests/envelope.rs index 800beb9..0507f0d 100644 --- a/crates/devup-mcp-figma/tests/envelope.rs +++ b/crates/devup-mcp-figma/tests/envelope.rs @@ -60,14 +60,24 @@ fn oversized_stringified_upstream_result_is_rejected_before_json_decode() { assert_eq!(error.details["category"], "upstreamResultJson"); } +/// The decoder's ceiling sits above the 15 KiB the producing script budgets +/// itself to, so a relay that re-serializes the JSON (pretty-printing, +/// different escaping) cannot inflate a valid envelope into a rejection. +/// A bound still exists, and this pins both halves of that: comfortably over +/// the producer's budget is accepted, far over the decoder's ceiling is not. #[test] -fn a_text_envelope_over_the_15kb_safety_margin_is_rejected() { - let envelope = mutate_envelope(|value| { +fn a_text_envelope_is_bounded_but_leaves_headroom_above_the_producer_budget() { + let inflated_by_a_relay = mutate_envelope(|value| { value["snapshot"]["nodes"][1]["fields"]["characters"] = json!("x".repeat(20 * 1024)); }); - let result = text_upstream_result(&envelope); + decode_fast_snapshot(&text_upstream_result(&inflated_by_a_relay), &target()) + .expect("20 KiB is over the producer budget but within the decoder's headroom"); - let error = decode_fast_snapshot(&result, &target()).expect_err("oversized text envelope"); + let oversized = mutate_envelope(|value| { + value["snapshot"]["nodes"][1]["fields"]["characters"] = json!("x".repeat(96 * 1024)); + }); + let error = decode_fast_snapshot(&text_upstream_result(&oversized), &target()) + .expect_err("oversized text envelope"); assert_eq!(error.details["category"], "textEnvelope"); } @@ -168,17 +178,36 @@ fn schema_target_and_resource_integrity_are_validated() { &target(), "resourceMissing", ); +} - // Corrupt the utf8Bytes counter *after* finalization (finalize_envelope's - // convergence loop would otherwise just recompute a correct value). - let mut bad_utf8_count: Value = serde_json::from_slice(&complete_envelope()).unwrap(); - bad_utf8_count["integrity"]["utf8Bytes"] = json!(1); - let bad_utf8_bytes = serde_json::to_vec(&bad_utf8_count).unwrap(); - assert_category( - text_upstream_result(&bad_utf8_bytes), - &target(), - "utf8Bytes", +/// `integrity.utf8Bytes` is the producer's self-measurement, and the envelope +/// reaches devup-mcp through a relay that may re-serialize the JSON. Both a +/// stale counter and a re-serialized (pretty-printed) payload must decode: +/// the structural checks above are what actually detect corruption, so a byte +/// count that disagrees with the received length is not an error. +#[test] +fn a_reserialized_envelope_decodes_even_though_its_byte_count_no_longer_matches() { + let original = complete_envelope(); + let value: Value = serde_json::from_slice(&original).unwrap(); + let declared = value["integrity"]["utf8Bytes"].as_u64().unwrap() as usize; + + // Pretty-printing changes the byte length without changing the content — + // exactly what a re-serializing relay does. + let reserialized = serde_json::to_vec_pretty(&value).unwrap(); + assert_ne!( + reserialized.len(), + declared, + "the pretty-printed payload must differ in length for this test to mean anything" ); + decode_fast_snapshot(&text_upstream_result(&reserialized), &target()) + .expect("a re-serialized envelope must still decode"); + + // A counter that is simply wrong is likewise not, by itself, corruption. + let mut stale = value; + stale["integrity"]["utf8Bytes"] = json!(1); + let stale = serde_json::to_vec(&stale).unwrap(); + decode_fast_snapshot(&text_upstream_result(&stale), &target()) + .expect("a stale utf8Bytes counter must not fail an otherwise valid envelope"); } #[test] @@ -374,7 +403,7 @@ fn complete_envelope() -> Vec { "type": "TEXT", "fields": { "textStyleId": "S:style1", - "characters": "테스트" + "characters": "Test" } } ], From 7f6803e9e5946bd33cb70df7babd394ba873e9bd Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:11:04 +0900 Subject: [PATCH 23/69] fix(figma): drop the now-dead utf8Bytes plumbing and move the metadata tests last Two clippy failures under -D warnings that cargo test cannot surface. Removing the byte-length comparison left EnvelopeIntegrity::utf8_bytes and ThemeEnvelopeIntegrity::utf8_bytes unread, and the validate_* functions taking a parameter they no longer use; both are gone, and serde simply ignores the key the producer still emits. The metadata test module was also placed above find_metadata, tripping items_after_test_module. --- crates/devup-mcp-figma/src/envelope.rs | 31 +++++----------- crates/devup-mcp-figma/src/metadata.rs | 50 +++++++++++++------------- 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/crates/devup-mcp-figma/src/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index 7ede995..1040955 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -58,13 +58,17 @@ struct EnvelopeSource { root_id: String, } +/// The producer also emits `utf8Bytes` here. It is deliberately absent: it is +/// the producer's measurement of its own serialized form, so comparing it +/// against what arrived only rejected relays that re-serialize the JSON. +/// Corruption is caught by the counts below plus `validate_resources`, which +/// read the content itself. Serde ignores the extra key on the wire. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnvelopeIntegrity { node_count: usize, variable_ref_count: usize, style_ref_count: usize, - utf8_bytes: usize, } #[derive(Debug, Deserialize)] @@ -85,6 +89,7 @@ struct ThemeEnvelopeSource { version: Option, } +/// `utf8Bytes` is omitted for the same reason as [`EnvelopeIntegrity`]. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeEnvelopeIntegrity { @@ -92,7 +97,6 @@ struct ThemeEnvelopeIntegrity { variable_count: usize, style_count: usize, unresolved_count: usize, - utf8_bytes: usize, } pub fn decode_fast_snapshot( @@ -138,7 +142,7 @@ fn decode_fast_snapshot_for_roots( return Err(invalid("textEnvelopeMissing")); }; let page = peek_page_cursor(&envelope.snapshot)?; - validate_envelope(&envelope, target, expected_root_ids, utf8_bytes, page)?; + validate_envelope(&envelope, target, expected_root_ids, page)?; Ok(FastSnapshotPayload { snapshot: envelope.snapshot, resources: UpstreamResult { @@ -163,7 +167,7 @@ pub fn decode_fast_theme( else { return Err(invalid("textEnvelopeMissing")); }; - validate_theme_envelope(&envelope, expected_file_key, utf8_bytes)?; + validate_theme_envelope(&envelope, expected_file_key)?; Ok(FastThemePayload { resources: UpstreamResult { raw: envelope.resources, @@ -269,7 +273,6 @@ fn validate_envelope( envelope: &Envelope, target: &FigmaTarget, expected_root_ids: &[String], - utf8_bytes: usize, page: PageCursor, ) -> Result<(), DevupError> { if envelope.schema_version != 1 @@ -291,18 +294,6 @@ fn validate_envelope( { return Err(invalid("targetMismatch")); } - // `integrity.utf8Bytes` is deliberately NOT compared against the received - // byte length. It is the producer's self-measurement, so requiring exact - // equality meant the envelope had to arrive byte-for-byte identical — - // which no relay that re-serializes JSON can guarantee, and an MCP host - // that hands the result to an agent to pass on is exactly such a relay. - // Nothing is lost: truncation or corruption cannot slip past the checks - // below (a truncated JSON document fails to parse at all, and nodeCount / - // resourceRefCount / validate_resources are computed from the content - // rather than from its serialized form). It stays on the wire, and in - // `FastTransportStats`, purely as a reported size. - let _ = utf8_bytes; - let mut node_ids = BTreeSet::new(); for node in &envelope.snapshot.nodes { if !node_ids.insert(node.id.as_str()) { @@ -347,7 +338,6 @@ fn validate_envelope( fn validate_theme_envelope( envelope: &ThemeEnvelope, expected_file_key: &str, - utf8_bytes: usize, ) -> Result<(), DevupError> { if envelope.schema_version != 1 || envelope @@ -360,11 +350,6 @@ fn validate_theme_envelope( if envelope.source.file_key != expected_file_key { return Err(invalid("targetMismatch")); } - // Not compared against the received length, for the same reason as - // `validate_envelope`: the collection/variable/style/unresolved counts - // below verify the content itself, and demanding a byte-exact match only - // broke relays that re-serialize JSON. - let _ = utf8_bytes; let resources = envelope .resources .as_object() diff --git a/crates/devup-mcp-figma/src/metadata.rs b/crates/devup-mcp-figma/src/metadata.rs index 69c6b3b..b435727 100644 --- a/crates/devup-mcp-figma/src/metadata.rs +++ b/crates/devup-mcp-figma/src/metadata.rs @@ -256,6 +256,31 @@ fn descendant_count(index: usize, nodes: &[XmlNode]) -> usize { .sum::() } +fn find_metadata(value: &Value) -> Option { + if let Ok(document) = serde_json::from_value::(value.clone()) + && !document.file_key.is_empty() + && !document.root_id.is_empty() + { + return Some(document); + } + match value { + Value::Object(object) => { + if let Some(Value::String(text)) = object.get("text") + && let Ok(parsed) = serde_json::from_str::(text) + && let Some(document) = find_metadata(&parsed) + { + return Some(document); + } + object.values().find_map(find_metadata) + } + Value::Array(values) => values.iter().find_map(find_metadata), + Value::String(text) => serde_json::from_str::(text) + .ok() + .and_then(|parsed| find_metadata(&parsed)), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -315,28 +340,3 @@ mod tests { assert!(parse("a < b and c > d").is_none()); } } - -fn find_metadata(value: &Value) -> Option { - if let Ok(document) = serde_json::from_value::(value.clone()) - && !document.file_key.is_empty() - && !document.root_id.is_empty() - { - return Some(document); - } - match value { - Value::Object(object) => { - if let Some(Value::String(text)) = object.get("text") - && let Ok(parsed) = serde_json::from_str::(text) - && let Some(document) = find_metadata(&parsed) - { - return Some(document); - } - object.values().find_map(find_metadata) - } - Value::Array(values) => values.iter().find_map(find_metadata), - Value::String(text) => serde_json::from_str::(text) - .ok() - .and_then(|parsed| find_metadata(&parsed)), - _ => None, - } -} From adf6fa6435a61e81693917e3e8c6e8256b4a70b5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:26:26 +0900 Subject: [PATCH 24/69] ci: release every MCP binary on a changepacks version bump, and require a changepack release.yml keys off the workspace version rather than a manual dispatch. Every crate sets version.workspace = true, so 'changepacks update' consuming the accumulated logs moves one number, and that number is the release signal. The job tags v only when the tag is absent, so an unrelated push to main re-runs it and exits at the detect step instead of cutting a duplicate. Each release builds devup-mcp and devup-mcp-visual for x86_64 Linux, x86_64 Windows, and a lipo-fused macOS universal binary, then attaches all six assets. The collect step fails when the count is not six rather than publishing a release that silently omits a platform. Release notes come from the pending changepack notes, falling back to the commit subject because 'changepacks update' consumes the logs before the release commit exists. The changepack job closes the loop the other way: a pull request that edits crates/ without adding a .changepacks/changepack_log_*.json is unreleasable, because the version never moves and release.yml never fires. It now fails in CI with the exact command to run instead of being discovered at release time. --- .../changepack_log_direct_oauth_path.json | 10 + .github/workflows/ci.yml | 55 ++++++ .github/workflows/release.yml | 180 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 .changepacks/changepack_log_direct_oauth_path.json create mode 100644 .github/workflows/release.yml diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json new file mode 100644 index 0000000..beb4f59 --- /dev/null +++ b/.changepacks/changepack_log_direct_oauth_path.json @@ -0,0 +1,10 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor", + "crates/devup-mcp-figma/Cargo.toml": "Minor", + "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", + "crates/devup-mcp-visual/Cargo.toml": "Patch" + }, + "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Adds a release workflow that builds devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and publishes them on a changepacks version bump, plus a CI gate that requires a changepack for any crate change.", + "date": "2026-09-03T17:20:00+09:00" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff3157f..90cec4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,61 @@ on: branches: [main] jobs: + # A release is cut from the workspace version, and that version only moves + # when `changepacks update` consumes a changepack log. A pull request that + # touches a crate without leaving a log is therefore unreleasable: the work + # merges, no version bump follows, and release.yml never fires. Catch it + # here rather than at release time. + changepack: + name: changepack required + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Require a changepack for crate changes + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + base="$(git merge-base "$BASE_SHA" "$HEAD_SHA")" + changed="$(git diff --name-only "$base" "$HEAD_SHA")" + + crate_changes="$(printf '%s\n' "$changed" | grep -E '^crates/' || true)" + if [ -z "$crate_changes" ]; then + echo "No crate sources touched; a changepack is not required." + exit 0 + fi + + log_changes="$(printf '%s\n' "$changed" \ + | grep -E '^\.changepacks/changepack_log_.*\.json$' || true)" + if [ -n "$log_changes" ]; then + echo "Changepack present:" + printf ' %s\n' $log_changes + exit 0 + fi + + { + echo "This pull request changes crate sources but adds no changepack log." + echo + echo "Without one the workspace version never moves, so the change" + echo "ships to main and is never released." + echo + echo " cargo install changepacks" + echo " changepacks" + echo + echo "Pick the affected crates, choose Major/Minor/Patch, and write" + echo "the release note, then commit the generated" + echo ".changepacks/changepack_log_*.json alongside your change." + echo + echo "--- crate files changed without a changepack ---" + printf ' %s\n' $crate_changes + } >&2 + exit 1 + verify: strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1d8f196 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: release + +# Fires when a changepacks version bump lands on main. +# +# `changepacks update` rewrites the workspace version from the accumulated +# `.changepacks/changepack_log_*.json` entries, so the version in Cargo.toml +# is the single source of truth for "a release happened". This workflow keys +# off that rather than off a manual dispatch: the tag `v` is created +# only when it does not already exist, which makes the job idempotent — an +# unrelated push to main re-runs it and it exits at the detect step. +# +# Every crate in the workspace sets `version.workspace = true`, so one bump +# releases the whole set together. + +on: + push: + branches: [main] + # Escape hatch for re-cutting a release whose upload failed. Detection still + # applies, so this cannot produce a duplicate tag. + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + detect: + name: detect version bump + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.check.outputs.should_release }} + version: ${{ steps.check.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Pinned rather than the runner default: the workspace is edition 2024 / + # resolver 3, which an older preinstalled cargo refuses to parse, and + # `cargo metadata` is how the version is read. + - uses: dtolnay/rust-toolchain@1.98.0 + - id: check + shell: bash + run: | + set -euo pipefail + # cargo metadata rather than grepping Cargo.toml: the version lives + # in [workspace.package] and is inherited, so the literal is not in + # the crate manifests at all. + version="$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "devup-mcp") | .version')" + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "could not read the devup-mcp version from cargo metadata" >&2 + exit 1 + fi + echo "version=$version" >>"$GITHUB_OUTPUT" + if git rev-parse "v$version" >/dev/null 2>&1; then + echo "v$version is already tagged; nothing to release" + echo "should_release=false" >>"$GITHUB_OUTPUT" + else + echo "v$version is new; releasing" + echo "should_release=true" >>"$GITHUB_OUTPUT" + fi + + build: + name: build (${{ matrix.os }}) + needs: detect + if: needs.detect.outputs.should_release == 'true' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + targets: x86_64-unknown-linux-gnu + suffix: linux-x86_64 + ext: "" + - os: windows-latest + targets: x86_64-pc-windows-msvc + suffix: windows-x86_64 + ext: ".exe" + - os: macos-latest + # Fused below into one universal binary so a single macOS asset + # runs on both Apple Silicon and Intel. + targets: aarch64-apple-darwin x86_64-apple-darwin + suffix: macos-universal + ext: "" + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.98.0 + - uses: Swatinem/rust-cache@v2 + - name: Build release binaries + shell: bash + env: + TARGETS: ${{ matrix.targets }} + SUFFIX: ${{ matrix.suffix }} + EXT: ${{ matrix.ext }} + OS: ${{ matrix.os }} + run: | + set -euo pipefail + for target in $TARGETS; do + rustup target add "$target" + cargo build --release --target "$target" \ + -p devup-mcp -p devup-mcp-visual + done + mkdir -p dist + for bin in devup-mcp devup-mcp-visual; do + out="dist/${bin}-${SUFFIX}${EXT}" + if [ "$OS" = "macos-latest" ]; then + lipo -create -output "$out" \ + "target/aarch64-apple-darwin/release/${bin}" \ + "target/x86_64-apple-darwin/release/${bin}" + file "$out" + else + set -- $TARGETS + cp "target/$1/release/${bin}${EXT}" "$out" + fi + done + ls -l dist + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.suffix }} + path: dist/* + if-no-files-found: error + retention-days: 7 + + release: + name: publish GitHub release + needs: [detect, build] + if: needs.detect.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + path: artifacts + - name: Collect assets + shell: bash + run: | + set -euo pipefail + mkdir -p dist + # download-artifact lays each artifact out as artifacts//. + find artifacts -type f -exec cp {} dist/ \; + ls -l dist + # Six assets: two binaries across three platforms. Fail loudly rather + # than cutting a release that silently omits a platform. + count="$(find dist -type f | wc -l)" + if [ "$count" -ne 6 ]; then + echo "expected 6 assets, found $count" >&2 + exit 1 + fi + - name: Extract this version's changepack notes + id: notes + shell: bash + run: | + set -euo pipefail + # `changepacks update` consumes the logs, so on the release commit + # they may already be gone. Fall back to the commit subject rather + # than failing the release over a missing changelog. + notes="" + if compgen -G '.changepacks/changepack_log_*.json' >/dev/null; then + notes="$(jq -rs 'map(.note // empty) | join("\n\n")' \ + .changepacks/changepack_log_*.json || true)" + fi + if [ -z "$notes" ]; then + notes="$(git log -1 --pretty=%s)" + fi + { + echo "body<>"$GITHUB_OUTPUT" + - uses: softprops/action-gh-release@v3 + with: + tag_name: v${{ needs.detect.outputs.version }} + name: v${{ needs.detect.outputs.version }} + body: ${{ steps.notes.outputs.body }} + files: dist/* + fail_on_unmatched_files: true From b8ff8c749a1bc7473134ced671d3875b8a570e3c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:34:41 +0900 Subject: [PATCH 25/69] ci: consolidate verification and release into one changepacks-driven workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled release.yml with the pattern devup-ui and the other org projects use: a single workflow file, and changepacks/action rather than a bespoke tag-detection script. The action already owns the whole lifecycle — it comments changepack status on a pull request, opens the Update Versions PR on main, then cuts tags and draft releases — so reimplementing version detection was both redundant and a second file that could drift. The draft-release receipt is what makes binary attachment safe. changepacks reports drafts through pending_releases; the build matrix compiles devup-mcp and devup-mcp-visual for x86_64 Linux, x86_64 Windows and a lipo-fused macOS universal binary and uploads all six assets onto the devup-mcp draft via release_assets_urls; finalize then publishes the drafts. Because finalize needs build, a release is never visible without its binaries attached. latestPackage now points at crates/devup-mcp/Cargo.toml so GitHub's Latest badge lands on the release that actually carries the binaries, rather than on whichever library crate happened to be tagged last. changepack-required stays, because the action only comments: a crate change with no changepack never moves the version and so never releases, and that should fail in review rather than be discovered as a missing release. --- .../changepack_log_direct_oauth_path.json | 2 +- .changepacks/config.json | 2 +- .github/workflows/ci.yml | 163 +++++++++++++++- .github/workflows/release.yml | 180 ------------------ 4 files changed, 158 insertions(+), 189 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json index beb4f59..5d2aad5 100644 --- a/.changepacks/changepack_log_direct_oauth_path.json +++ b/.changepacks/changepack_log_direct_oauth_path.json @@ -5,6 +5,6 @@ "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", "crates/devup-mcp-visual/Cargo.toml": "Patch" }, - "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Adds a release workflow that builds devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and publishes them on a changepacks version bump, plus a CI gate that requires a changepack for any crate change.", + "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases.", "date": "2026-09-03T17:20:00+09:00" } diff --git a/.changepacks/config.json b/.changepacks/config.json index f54c6c6..3816df6 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -1,5 +1,5 @@ { "ignore": ["**", "!/crates/*/Cargo.toml"], "baseBranch": "main", - "latestPackage": null + "latestPackage": "crates/devup-mcp/Cargo.toml" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90cec4a..56cb121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,40 @@ name: CI +# One workflow, matching devup-ui and the other org projects: verification, +# changepacks version management, binary builds and release publication all +# live here rather than in a second file that can drift out of step. +# +# Release flow (driven by changepacks/action, not by hand): +# 1. A pull request touching crates/ must carry a changepack. `changepacks` +# comments the detected packs; `changepack-required` makes it a gate. +# 2. On push to main with pending changepacks, the action opens an +# "Update Versions" pull request that runs `changepacks update`. +# 3. Merging that PR leaves no changepacks, so the action cuts tags and +# *draft* releases and reports them in `pending_releases`. +# 4. `build` compiles every MCP binary for all three platforms and uploads +# them onto those drafts. +# 5. `finalize` publishes the drafts, but only once the uploads succeeded — +# so a release is never visible without its binaries attached. + on: - pull_request: push: branches: [main] + pull_request: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: - # A release is cut from the workspace version, and that version only moves - # when `changepacks update` consumes a changepack log. A pull request that - # touches a crate without leaving a log is therefore unreleasable: the work - # merges, no version bump follows, and release.yml never fires. Catch it - # here rather than at release time. - changepack: + # The action comments the changepack status on a pull request but does not + # fail it. A crate change that ships without a changepack never moves the + # version, so it never releases — this turns that silent outcome into a + # red check with the command to fix it. + changepack-required: name: changepack required if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -82,3 +105,129 @@ jobs: - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo insta test --workspace --all-features --check - run: cargo build --workspace --release + + changepacks: + name: changepacks + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # changepacks diffs HEAD against the previous release commit; the + # default shallow fetch grafts away every parent, so that lookup + # fails and the release never publishes. + fetch-depth: 0 + fetch-tags: true + - uses: changepacks/action@main + id: changepacks + with: + token: ${{ secrets.GITHUB_TOKEN }} + create_release: true + outputs: + changepacks: ${{ steps.changepacks.outputs.changepacks }} + release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }} + pending_releases: ${{ steps.changepacks.outputs.pending_releases }} + + build: + name: build (${{ matrix.os }}) + needs: changepacks + # Only when a draft release is actually waiting for assets. On a pull + # request, or on a push that merely opened the Update Versions PR, there + # is nothing to attach to. + if: >- + needs.changepacks.outputs.pending_releases != '' + && needs.changepacks.outputs.pending_releases != '{}' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + targets: x86_64-unknown-linux-gnu + suffix: linux-x86_64 + ext: "" + - os: windows-latest + targets: x86_64-pc-windows-msvc + suffix: windows-x86_64 + ext: ".exe" + - os: macos-latest + # Fused into one universal binary so a single macOS asset runs on + # both Apple Silicon and Intel. + targets: aarch64-apple-darwin x86_64-apple-darwin + suffix: macos-universal + ext: "" + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.98.0 + - uses: Swatinem/rust-cache@v2 + - name: Build release binaries + shell: bash + env: + TARGETS: ${{ matrix.targets }} + SUFFIX: ${{ matrix.suffix }} + EXT: ${{ matrix.ext }} + OS: ${{ matrix.os }} + run: | + set -euo pipefail + for target in $TARGETS; do + rustup target add "$target" + cargo build --release --target "$target" -p devup-mcp -p devup-mcp-visual + done + mkdir -p dist + for bin in devup-mcp devup-mcp-visual; do + out="dist/${bin}-${SUFFIX}${EXT}" + if [ "$OS" = "macos-latest" ]; then + lipo -create -output "$out" \ + "target/aarch64-apple-darwin/release/${bin}" \ + "target/x86_64-apple-darwin/release/${bin}" + file "$out" + else + set -- $TARGETS + cp "target/$1/release/${bin}${EXT}" "$out" + fi + done + ls -l dist + - name: Upload binaries onto the draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }} + run: | + set -euo pipefail + # release_assets_urls maps project path -> asset upload URL. The + # binaries belong to the devup-mcp crate; the library crates get + # their own releases with no assets. + upload="$(printf '%s' "$ASSET_URLS" \ + | jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')" + if [ -z "$upload" ]; then + echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2 + printf '%s\n' "$ASSET_URLS" >&2 + exit 1 + fi + # Drop the RFC 6570 template suffix, e.g. "{?name,label}". + upload="${upload%%\{*}" + for file in dist/*; do + name="$(basename "$file")" + echo "uploading $name" + curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$file" \ + "${upload}?name=${name}" >/dev/null + done + + finalize: + name: finalize release + needs: [changepacks, build] + if: >- + needs.changepacks.outputs.pending_releases != '' + && needs.changepacks.outputs.pending_releases != '{}' + runs-on: ubuntu-latest + steps: + # Finalize-only: the action neither installs changepacks nor touches the + # repository here, so no checkout is needed. Running it after `build` + # is what guarantees a published release always has its binaries. + - uses: changepacks/action@main + with: + token: ${{ secrets.GITHUB_TOKEN }} + finalize_releases: ${{ needs.changepacks.outputs.pending_releases }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1d8f196..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: release - -# Fires when a changepacks version bump lands on main. -# -# `changepacks update` rewrites the workspace version from the accumulated -# `.changepacks/changepack_log_*.json` entries, so the version in Cargo.toml -# is the single source of truth for "a release happened". This workflow keys -# off that rather than off a manual dispatch: the tag `v` is created -# only when it does not already exist, which makes the job idempotent — an -# unrelated push to main re-runs it and it exits at the detect step. -# -# Every crate in the workspace sets `version.workspace = true`, so one bump -# releases the whole set together. - -on: - push: - branches: [main] - # Escape hatch for re-cutting a release whose upload failed. Detection still - # applies, so this cannot produce a duplicate tag. - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - detect: - name: detect version bump - runs-on: ubuntu-latest - outputs: - should_release: ${{ steps.check.outputs.should_release }} - version: ${{ steps.check.outputs.version }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - # Pinned rather than the runner default: the workspace is edition 2024 / - # resolver 3, which an older preinstalled cargo refuses to parse, and - # `cargo metadata` is how the version is read. - - uses: dtolnay/rust-toolchain@1.98.0 - - id: check - shell: bash - run: | - set -euo pipefail - # cargo metadata rather than grepping Cargo.toml: the version lives - # in [workspace.package] and is inherited, so the literal is not in - # the crate manifests at all. - version="$(cargo metadata --no-deps --format-version 1 \ - | jq -r '.packages[] | select(.name == "devup-mcp") | .version')" - if [ -z "$version" ] || [ "$version" = "null" ]; then - echo "could not read the devup-mcp version from cargo metadata" >&2 - exit 1 - fi - echo "version=$version" >>"$GITHUB_OUTPUT" - if git rev-parse "v$version" >/dev/null 2>&1; then - echo "v$version is already tagged; nothing to release" - echo "should_release=false" >>"$GITHUB_OUTPUT" - else - echo "v$version is new; releasing" - echo "should_release=true" >>"$GITHUB_OUTPUT" - fi - - build: - name: build (${{ matrix.os }}) - needs: detect - if: needs.detect.outputs.should_release == 'true' - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - targets: x86_64-unknown-linux-gnu - suffix: linux-x86_64 - ext: "" - - os: windows-latest - targets: x86_64-pc-windows-msvc - suffix: windows-x86_64 - ext: ".exe" - - os: macos-latest - # Fused below into one universal binary so a single macOS asset - # runs on both Apple Silicon and Intel. - targets: aarch64-apple-darwin x86_64-apple-darwin - suffix: macos-universal - ext: "" - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@1.98.0 - - uses: Swatinem/rust-cache@v2 - - name: Build release binaries - shell: bash - env: - TARGETS: ${{ matrix.targets }} - SUFFIX: ${{ matrix.suffix }} - EXT: ${{ matrix.ext }} - OS: ${{ matrix.os }} - run: | - set -euo pipefail - for target in $TARGETS; do - rustup target add "$target" - cargo build --release --target "$target" \ - -p devup-mcp -p devup-mcp-visual - done - mkdir -p dist - for bin in devup-mcp devup-mcp-visual; do - out="dist/${bin}-${SUFFIX}${EXT}" - if [ "$OS" = "macos-latest" ]; then - lipo -create -output "$out" \ - "target/aarch64-apple-darwin/release/${bin}" \ - "target/x86_64-apple-darwin/release/${bin}" - file "$out" - else - set -- $TARGETS - cp "target/$1/release/${bin}${EXT}" "$out" - fi - done - ls -l dist - - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.suffix }} - path: dist/* - if-no-files-found: error - retention-days: 7 - - release: - name: publish GitHub release - needs: [detect, build] - if: needs.detect.outputs.should_release == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - path: artifacts - - name: Collect assets - shell: bash - run: | - set -euo pipefail - mkdir -p dist - # download-artifact lays each artifact out as artifacts//. - find artifacts -type f -exec cp {} dist/ \; - ls -l dist - # Six assets: two binaries across three platforms. Fail loudly rather - # than cutting a release that silently omits a platform. - count="$(find dist -type f | wc -l)" - if [ "$count" -ne 6 ]; then - echo "expected 6 assets, found $count" >&2 - exit 1 - fi - - name: Extract this version's changepack notes - id: notes - shell: bash - run: | - set -euo pipefail - # `changepacks update` consumes the logs, so on the release commit - # they may already be gone. Fall back to the commit subject rather - # than failing the release over a missing changelog. - notes="" - if compgen -G '.changepacks/changepack_log_*.json' >/dev/null; then - notes="$(jq -rs 'map(.note // empty) | join("\n\n")' \ - .changepacks/changepack_log_*.json || true)" - fi - if [ -z "$notes" ]; then - notes="$(git log -1 --pretty=%s)" - fi - { - echo "body<>"$GITHUB_OUTPUT" - - uses: softprops/action-gh-release@v3 - with: - tag_name: v${{ needs.detect.outputs.version }} - name: v${{ needs.detect.outputs.version }} - body: ${{ steps.notes.outputs.body }} - files: dist/* - fail_on_unmatched_files: true From 78bafa7aef861b5803766c42bdb9767d26e9c10b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 17:42:14 +0900 Subject: [PATCH 26/69] fix(server): accept an output root reached through a symlink OutputPolicy canonicalised each root when it opened it, but compared an incoming absolute outputPath against that canonical prefix without resolving the request the same way. A caller passing a path under the spelling it was configured with therefore failed strip_prefix and was refused with 'outputPath is outside the allowed root'. On macOS this was the normal case, not an edge case: /tmp and std::env::temp_dir() both reach their targets through /var -> /private/var, so composite_export, downstream_integration and source_orchestration failed there on every run, and output_policy compared a canonical display_path against a non-canonical expectation. It reproduces anywhere a project path traverses a symlink. The root now remembers both spellings and resolve() accepts either. Nothing is loosened: the remainder after the prefix still goes through normalize_relative_file, which rejects .. and absolute components, and symlinked ancestors inside the root are still refused. A new unix test pins the guarantee with an explicit symlink rather than relying on the OS to provide one, and asserts that accepting both spellings still refuses an escape through either. --- .../changepack_log_direct_oauth_path.json | 2 +- crates/devup-mcp/Cargo.toml | 4 + crates/devup-mcp/src/server/output.rs | 26 +++++- crates/devup-mcp/tests/output_policy.rs | 82 +++++++++++++++++-- 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json index 5d2aad5..4d53983 100644 --- a/.changepacks/changepack_log_direct_oauth_path.json +++ b/.changepacks/changepack_log_direct_oauth_path.json @@ -5,6 +5,6 @@ "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", "crates/devup-mcp-visual/Cargo.toml": "Patch" }, - "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases.", + "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var.", "date": "2026-09-03T17:20:00+09:00" } diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index 121b5af..4e15ef4 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -26,4 +26,8 @@ tracing-subscriber.workspace = true [dev-dependencies] axum.workspace = true +# Also a normal dependency. Tests need it too, to canonicalise an expected path +# the same way OutputPolicy does — std::fs::canonicalize would return a `\\?\` +# UNC path on Windows and never match. +dunce.workspace = true reqwest.workspace = true diff --git a/crates/devup-mcp/src/server/output.rs b/crates/devup-mcp/src/server/output.rs index f23ec42..0e4af6b 100644 --- a/crates/devup-mcp/src/server/output.rs +++ b/crates/devup-mcp/src/server/output.rs @@ -22,7 +22,23 @@ pub struct OutputPolicy { struct OutputRoot { dir: Dir, + /// The canonical location. Every path devup-mcp reports back is built from + /// this, so a caller always learns where a file actually landed. display_path: PathBuf, + /// The spelling this root was configured with, which may reach + /// `display_path` through a symlink. + /// + /// On macOS that is the normal case rather than an edge case: `/tmp` and + /// `/var` are symlinks into `/private`, and `std::env::temp_dir()` returns + /// a path under `/var/folders`. A client then passes an `outputPath` under + /// the same unresolved prefix it was given, which no longer shares a + /// prefix with the canonicalised root. Keeping both spellings lets + /// [`OutputPolicy::resolve`] accept either without loosening a single + /// check: whatever remains after the prefix is stripped still goes through + /// `normalize_relative_file`, which rejects `..`, absolute components and + /// unsafe names, and symlinked ancestors inside the root are still + /// refused by `reject_existing_symlink_ancestors`. + requested_path: PathBuf, } #[derive(Clone)] @@ -98,7 +114,11 @@ impl OutputPolicy { } let dir = Dir::open_ambient_dir(&display_path, ambient_authority()) .map_err(|error| invalid_path(format!("Cannot open the output root: {error}")))?; - opened.push(Arc::new(OutputRoot { dir, display_path })); + opened.push(Arc::new(OutputRoot { + dir, + display_path, + requested_path: root, + })); } Ok(Self { roots: Arc::new(opened), @@ -115,7 +135,11 @@ impl OutputPolicy { self.roots .iter() .find_map(|root| { + // Either spelling of the root is accepted: the resolved + // one, and the one it was configured with. See + // `OutputRoot::requested_path` for why the two differ. path.strip_prefix(&root.display_path) + .or_else(|_| path.strip_prefix(&root.requested_path)) .ok() .map(|relative| (root.clone(), relative.to_path_buf())) }) diff --git a/crates/devup-mcp/tests/output_policy.rs b/crates/devup-mcp/tests/output_policy.rs index 9d57ee4..7586a6a 100644 --- a/crates/devup-mcp/tests/output_policy.rs +++ b/crates/devup-mcp/tests/output_policy.rs @@ -1,11 +1,15 @@ use std::{ fs::{self, File, FileTimes}, - path::PathBuf, + path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use devup_mcp::server::output::{OutputPolicy, OutputTransaction}; +/// Deliberately returns the spelling `std::env::temp_dir()` gives, symlinks and +/// all. On macOS that is under `/var/folders`, which resolves to +/// `/private/var/folders`, so configuring a policy from this exercises the case +/// where the configured root and the canonical root differ. fn unique_temp_dir(label: &str) -> anyhow::Result { let path = std::env::temp_dir().join(format!( "devup-mcp-{label}-{}-{}", @@ -16,6 +20,12 @@ fn unique_temp_dir(label: &str) -> anyhow::Result { Ok(path) } +/// Where the policy will actually report files, which is the canonical location +/// rather than the configured spelling. Assertions compare against this. +fn canonical(path: &Path) -> PathBuf { + dunce::canonicalize(path).expect("canonicalize an existing temp directory") +} + #[test] fn resolves_only_files_inside_preopened_roots() -> anyhow::Result<()> { let root = unique_temp_dir("allowed-root")?; @@ -25,11 +35,16 @@ fn resolves_only_files_inside_preopened_roots() -> anyhow::Result<()> { let relative = policy.resolve("nested/Component.tsx")?; assert_eq!( relative.display_path(), - root.join("nested").join("Component.tsx") + canonical(&root).join("nested").join("Component.tsx") ); + // Spelled exactly as the root was configured, which on macOS is not the + // canonical path. This must resolve, and must report the canonical one. let absolute_path = root.join("theme").join("devup.json"); let absolute = policy.resolve(absolute_path.to_str().unwrap())?; - assert_eq!(absolute.display_path(), absolute_path); + assert_eq!( + absolute.display_path(), + canonical(&root).join("theme").join("devup.json") + ); for invalid in [ "", @@ -53,6 +68,57 @@ fn resolves_only_files_inside_preopened_roots() -> anyhow::Result<()> { Ok(()) } +/// A root reached through a symlink is canonicalised when the policy opens it, +/// so the path devup-mcp reports back no longer shares a prefix with the one +/// the caller was given. Before this was handled, every such `outputPath` was +/// refused with "outputPath is outside the allowed root" — which on macOS is +/// not an edge case at all, since `/tmp` and `std::env::temp_dir()` both reach +/// their targets through `/var -> /private/var`. +/// +/// Asserted here with an explicit symlink so the guarantee holds on every +/// platform with symlinks, instead of only where the OS happens to provide one. +#[cfg(unix)] +#[test] +fn accepts_a_root_reached_through_a_symlink_in_either_spelling() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let real = unique_temp_dir("symlink-spelling")?; + let link = real.with_file_name(format!( + "{}-link", + real.file_name().unwrap().to_string_lossy() + )); + symlink(&real, &link)?; + + // Configured through the symlink, exactly as a client whose project path + // traverses one would. + let policy = OutputPolicy::from_roots(vec![link.clone()])?; + let expected = canonical(&real).join("Component.tsx"); + + let through_link = policy.resolve(link.join("Component.tsx").to_str().unwrap())?; + assert_eq!(through_link.display_path(), expected); + + // The resolved spelling must keep working too. + let through_real = policy.resolve(expected.to_str().unwrap())?; + assert_eq!(through_real.display_path(), expected); + + // Accepting both spellings must not accept an escape through either. + let outside = unique_temp_dir("symlink-spelling-outside")?; + assert!( + policy + .resolve(outside.join("escape.tsx").to_str().unwrap()) + .is_err() + ); + assert!(policy.resolve("../escape.tsx").is_err()); + + drop(through_link); + drop(through_real); + drop(policy); + fs::remove_file(&link)?; + fs::remove_dir_all(real)?; + fs::remove_dir_all(outside)?; + Ok(()) +} + #[cfg(unix)] #[test] fn rejects_a_symlink_parent_that_escapes_the_root() -> anyhow::Result<()> { @@ -117,10 +183,16 @@ fn commits_multiple_outputs_only_after_every_stage_succeeds() -> anyhow::Result< b"export const Component = 1;\n" ); assert_eq!(fs::read(root.join("theme/devup.json"))?, br#"{"theme":{}}"#); - assert_eq!(paths["tsx"], root.join("Component.tsx").to_string_lossy()); + assert_eq!( + paths["tsx"], + canonical(&root).join("Component.tsx").to_string_lossy() + ); assert_eq!( paths["devupJson"], - root.join("theme").join("devup.json").to_string_lossy() + canonical(&root) + .join("theme") + .join("devup.json") + .to_string_lossy() ); drop(policy); From f39d3cf78b5f2e8129a87005176b169fc24ec4e0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 18:25:06 +0900 Subject: [PATCH 27/69] fix(server): answer a Section link with its screens on the direct path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast snapshot script throws DEVUP_TARGET_IS_SECTION when its target is a Section, and MCP reports a thrown script error as a *successful* tool call whose result carries isError. The direct path matched only on Err, so it passed that result to accept, which looked for snapshot data that was never there and failed with 'snapshot data not found' — leaving a Section link with no way to discover the screens inside it. The handoff path has always converted it into a rejection. Rejecting it on the direct path too lets the collector switch to the section index and return selection_required with the candidate screens, which is the documented contract and what devup_figma_explore already did. The existing section test never caught this because its upstream answers the very first call with the index, skipping the throw entirely. The new test reproduces the real sequence — isError throw, then the index retry — and was confirmed to fail without the fix ('metadata not found in the Figma MCP response') and pass with it. --- .../changepack_log_direct_oauth_path.json | 2 +- crates/devup-mcp/src/server/handoff.rs | 10 ++- crates/devup-mcp/src/server/mod.rs | 19 +++++ crates/devup-mcp/tests/section_export.rs | 69 +++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json index 4d53983..3ca2d6f 100644 --- a/.changepacks/changepack_log_direct_oauth_path.json +++ b/.changepacks/changepack_log_direct_oauth_path.json @@ -5,6 +5,6 @@ "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", "crates/devup-mcp-visual/Cargo.toml": "Patch" }, - "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var.", + "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens.", "date": "2026-09-03T17:20:00+09:00" } diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index de6890a..06718f1 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -358,7 +358,15 @@ impl HandoffStore { } } -fn is_section_error_result(value: &Value) -> bool { +/// Whether an upstream result is the fast snapshot script reporting that its +/// target is a Section. +/// +/// MCP reports a thrown script error as a *successful* tool call whose result +/// carries `isError`, so this cannot be spotted by matching on `Err`. Both the +/// handoff path and the direct path in `server::mod` need the same test: a +/// Section has no single screen to convert, and the collector answers it by +/// switching to the section index and offering selectable screens instead. +pub(crate) fn is_section_error_result(value: &Value) -> bool { value.get("isError").and_then(Value::as_bool) == Some(true) && value.to_string().contains("DEVUP_TARGET_IS_SECTION") } diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index ee04930..f0a0442 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -294,6 +294,25 @@ impl DevupServer { CollectorStep::Call(planned) => { let call_id = planned.id.clone(); match self.services.upstream.call_read_tool(planned.call).await { + // A Section target is not a failed call — the script + // throws, and MCP delivers that as a successful result + // carrying `isError`. Handing it to `accept` made the + // collector look for snapshot data that was never + // there and report "snapshot data not found", hiding + // the one thing the caller needed to know. Rejecting + // it lets the collector switch to the section index + // and answer with the screens inside, which is what + // the handoff path has always done. + Ok(result) if handoff::is_section_error_result(&result.raw) => { + let error = DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "DEVUP_TARGET_IS_SECTION", + false, + ); + if !collector.reject(&call_id, &error)? { + return Err(error); + } + } Ok(result) => collector.accept(&call_id, result)?, Err(error) if collector.reject(&call_id, &error)? => continue, Err(error) => return Err(error), diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index 3a9d120..65e46e0 100644 --- a/crates/devup-mcp/tests/section_export.rs +++ b/crates/devup-mcp/tests/section_export.rs @@ -196,6 +196,75 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o Ok(()) } +/// The real fast snapshot script does not return a Section snapshot: it throws +/// `DEVUP_TARGET_IS_SECTION`, and MCP delivers a thrown error as a *successful* +/// call whose result carries `isError`. +/// +/// `SectionUpstream` above answers the very first call with the index, so it +/// never exercises that step — which is how the direct path came to hand the +/// thrown error straight to `accept` and fail with "snapshot data not found", +/// leaving a Section link with no way to discover the screens inside it. The +/// handoff path had always converted it into a rejection. +#[derive(Debug, Default)] +struct ThrowingSectionUpstream(AtomicUsize); + +#[async_trait] +impl FigmaUpstream for ThrowingSectionUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + // Keyed on call order rather than script variant, so the test pins the + // recovery itself and not which script the collector retries with. + if self.0.fetch_add(1, Ordering::SeqCst) == 0 { + return Ok(UpstreamResult { + raw: json!({ + "content": [{"type": "text", "text": "Error: DEVUP_TARGET_IS_SECTION"}], + "isError": true + }), + }); + } + Ok(compact_section_index_result()) + } +} + +#[tokio::test] +async fn a_thrown_section_error_on_the_direct_path_returns_selectable_screens() -> anyhow::Result<()> +{ + let upstream = Arc::new(ThrowingSectionUpstream::default()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let selection = call( + &client, + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }), + ) + .await?; + + assert_eq!(selection["status"], "selection_required"); + assert_eq!(selection["targetKind"], "section"); + assert!(selection.get("tsx").is_none()); + let candidates = selection["selection"]["candidates"] + .as_array() + .expect("a Section answers with the screens inside it"); + assert!(!candidates.is_empty()); + // The throw, then the index retry. + assert_eq!(upstream.0.load(Ordering::SeqCst), 2); + + client.cancel().await?; + task.await??; + Ok(()) +} + #[test] fn actual_wquw_151_section_fixture_preserves_the_ten_screen_index() { let fixture: Value = serde_json::from_str(include_str!("fixtures/wquw-151-section.json")) From 42c73fc5b451dc7d5d3a59dc1eea14cac68b51e4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 18:44:41 +0900 Subject: [PATCH 28/69] fix(figma): export SVG assets, and say what a missing payload actually contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every SVG asset request failed with 'asset export response does not contain the requested binary' while PNG succeeded. The cause was upstream, not local: Figma's remote MCP returns a written PNG back as an image attachment, but does not return a written .svg at all, so the response carried only the descriptor and the bytes never arrived. The instrumented error made this visible in one run — expectedMimeType image/svg+xml against observed [type=text mimeType= carries=[text]]. SVG is now exported with SVG_STRING and carried inline beside the descriptor, bounded at 12 KiB so it cannot overflow the text-response limit. The payload search accepts a text payload as well as base64 and steps through the JSON encoding of a text block to reach it, mirroring what find_descriptor already did. Verified end to end against the real file: 167 bytes written to disk with a sha256 matching the descriptor. The missing-payload error now reports the content shapes and mime types the response did carry, so 'nothing came back', 'wrong mime type' and 'a field this search does not read' stay three distinguishable failures rather than one opaque sentence. Server instructions gain four rules the last round of testing showed were needed: the generated component name and the asset paths are starting points rather than contracts, a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist yet, and resource delivery is preferred over inlining bytes in every response. --- .../changepack_log_direct_oauth_path.json | 2 +- crates/devup-mcp-figma/src/assets.rs | 123 ++++++++++++++++-- crates/devup-mcp-figma/src/scripts/assets.js | 24 +++- crates/devup-mcp-figma/tests/assets.rs | 86 ++++++++++++ crates/devup-mcp/src/server/mod.rs | 6 +- 5 files changed, 223 insertions(+), 18 deletions(-) diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json index 3ca2d6f..f971ed7 100644 --- a/.changepacks/changepack_log_direct_oauth_path.json +++ b/.changepacks/changepack_log_direct_oauth_path.json @@ -5,6 +5,6 @@ "crates/devup-mcp-devup-ui/Cargo.toml": "Patch", "crates/devup-mcp-visual/Cargo.toml": "Patch" }, - "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens.", + "note": "Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens. Fixes SVG asset export, which failed for every request while PNG worked: Figma's remote MCP returns a written PNG as an image attachment but does not return a written .svg at all, so the bytes never reached devup-mcp. SVG is now exported as a string and carried inline beside the descriptor under a bounded size, and the payload search steps through the JSON encoding of a text block and accepts a text payload as well as base64. The missing-payload error now reports which content shapes and mime types the response actually carried, so an absent attachment, a wrong mime type and an unread field stay distinguishable. Adds server instructions covering that the generated component name and asset paths are starting points rather than contracts, that a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist, and that resource delivery should be preferred over inlining bytes.", "date": "2026-09-03T17:20:00+09:00" } diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index feab71e..e75ea6a 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -1,6 +1,6 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use crate::{DevupError, Diagnostic, ErrorCode, Snapshot, UpstreamResult}; @@ -275,11 +275,36 @@ pub fn asset_export_from_result( error_code: descriptor.error_code, }); } - let data = find_binary(&result.raw, request.format.mime_type()) - .ok_or_else(|| invalid("asset export response does not contain the requested binary."))?; - let bytes = STANDARD - .decode(data.as_bytes()) - .map_err(|_| invalid("asset export binary base64 is invalid."))?; + let payload = find_payload(&result.raw, request.format.mime_type()).ok_or_else(|| { + // Which shapes the response *did* carry. Without this the failure is + // indistinguishable between "no attachment came back", "it came back + // under a different mime type" and "it came back in a field this + // search does not read" — three very different bugs. + DevupError::with_details( + ErrorCode::DevupSnapshotUnsupported, + "asset export response does not contain the requested binary.", + false, + json!({ + "expectedMimeType": request.format.mime_type(), + "observed": observed_payload_shapes(&result.raw), + }), + ) + })?; + let (bytes, data) = match payload { + AssetPayload::Base64(data) => { + let bytes = STANDARD + .decode(data.as_bytes()) + .map_err(|_| invalid("asset export binary base64 is invalid."))?; + (bytes, data) + } + // Re-encoded so every consumer downstream still receives base64, + // regardless of how the upstream happened to carry the payload. + AssetPayload::Text(text) => { + let bytes = text.into_bytes(); + let data = STANDARD.encode(&bytes); + (bytes, data) + } + }; if bytes.is_empty() || bytes.len() > MAX_ASSET_BYTES || descriptor.byte_length != Some(bytes.len()) @@ -341,29 +366,99 @@ fn find_descriptor(value: &Value) -> Option { } } -fn find_binary(value: &Value, mime_type: &str) -> Option { +/// How an upstream carried the exported asset. +enum AssetPayload { + /// An image content block or a blob resource, which are base64. + Base64(String), + /// A text resource. MCP models a text-based document — SVG being the one + /// devup-mcp exports — as `text` holding the document itself rather than + /// base64 of it, so an SVG export used to be invisible to a search that + /// only looked for `data`/`blob` and every request failed with "asset + /// export response does not contain the requested binary". + Text(String), +} + +fn find_payload(value: &Value, mime_type: &str) -> Option { match value { Value::Object(object) => { - let observed_mime = object.get("mimeType").and_then(Value::as_str); - if observed_mime == Some(mime_type) - && let Some(data) = object + if object.get("mimeType").and_then(Value::as_str) == Some(mime_type) { + // Base64 first: when a payload offers both, the binary form is + // the exact bytes, while `text` may be a lossy preview. + if let Some(data) = object .get("data") .or_else(|| object.get("blob")) .and_then(Value::as_str) - { - return Some(data.to_owned()); + { + return Some(AssetPayload::Base64(data.to_owned())); + } + if let Some(text) = object.get("text").and_then(Value::as_str) { + return Some(AssetPayload::Text(text.to_owned())); + } } object .values() - .find_map(|value| find_binary(value, mime_type)) + .find_map(|value| find_payload(value, mime_type)) } Value::Array(values) => values .iter() - .find_map(|value| find_binary(value, mime_type)), + .find_map(|value| find_payload(value, mime_type)), + // The descriptor — and, for SVG, the payload inlined beside it — + // arrives as JSON inside a text content block, so the search has to + // step through that encoding exactly as `find_descriptor` does. + Value::String(text) => serde_json::from_str::(text) + .ok() + .and_then(|value| find_payload(&value, mime_type)), _ => None, } } +/// Describes every payload-carrying object in a response by its `type` and +/// `mimeType` and which of `data`/`blob`/`text` it holds, without ever +/// including the payload itself. Bounded so a large response cannot turn a +/// diagnostic into another problem. +fn observed_payload_shapes(value: &Value) -> Vec { + fn walk(value: &Value, found: &mut Vec) { + if found.len() >= 12 { + return; + } + match value { + Value::Object(object) => { + let carriers: Vec<&str> = ["data", "blob", "text", "uri"] + .into_iter() + .filter(|key| object.contains_key(*key)) + .collect(); + if !carriers.is_empty() { + let kind = object + .get("type") + .and_then(Value::as_str) + .unwrap_or(""); + let mime = object + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or(""); + found.push(format!( + "type={kind} mimeType={mime} carries=[{}]", + carriers.join(",") + )); + } + for child in object.values() { + walk(child, found); + } + } + Value::Array(values) => { + for child in values { + walk(child, found); + } + } + _ => {} + } + } + + let mut found = Vec::new(); + walk(value, &mut found); + found +} + fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes) .iter() diff --git a/crates/devup-mcp-figma/src/scripts/assets.js b/crates/devup-mcp-figma/src/scripts/assets.js index 11ef730..1fc3f84 100644 --- a/crates/devup-mcp-figma/src/scripts/assets.js +++ b/crates/devup-mcp-figma/src/scripts/assets.js @@ -41,15 +41,31 @@ try { return failed("DEVUP_ASSET_FORMAT_UNSUPPORTED"); } const scale = Math.min(4, Math.max(1, Math.floor(Number(options.scale) || 1))); - const settings = { format }; + // SVG is exported as a string and carried back inline. Figma's remote MCP + // does not return a written `.svg` as an attachment the way it does a PNG, + // so writing the file alone left the caller holding a descriptor and no + // bytes at all, and every SVG request failed. SVG is text and small, so an + // inline copy is bounded well under the text-response limit; anything + // larger is reported rather than silently truncated. + const inlineSvg = format === "SVG"; + const settings = { format: inlineSvg ? "SVG_STRING" : format }; if (format === "PNG" || format === "JPG") { settings.constraint = { type: "SCALE", value: scale }; } const exported = await node.exportAsync(settings); - const bytes = exported instanceof Uint8Array ? exported : new Uint8Array(exported); + const svgText = inlineSvg && typeof exported === "string" ? exported : null; + const bytes = + svgText === null + ? exported instanceof Uint8Array + ? exported + : new Uint8Array(exported) + : devupUtf8Encode(svgText); if (bytes.length === 0 || bytes.length > 8 * 1024 * 1024) { return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE"); } + if (svgText !== null && bytes.length > 12 * 1024) { + return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE"); + } const sha256 = devupSha256(bytes); figma.io.write(`devup-asset-${options.assetId.replace(/[^A-Za-z0-9_-]/g, "_")}.${String(options.format).toLowerCase()}`, bytes); return { @@ -65,6 +81,10 @@ try { status: "exported", byteLength: bytes.length, sha256, + // Present only for SVG. `mimeType` is what lets the Rust side recognise + // this as the payload rather than as ordinary descriptor prose. + mimeType: svgText === null ? null : "image/svg+xml", + text: svgText, errorCode: null, }; } catch (_) { diff --git a/crates/devup-mcp-figma/tests/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index 5f3f183..7950197 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -7,6 +7,7 @@ use devup_mcp_figma::{ asset_export_from_result, discover_asset_manifest, }; use serde_json::{Map, json}; +use sha2::Digest as _; fn node(id: &str, node_type: &str, fields: serde_json::Value) -> RawNode { RawNode { @@ -96,6 +97,91 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu ); } +/// Figma's remote MCP returns a written PNG as an image attachment but does +/// not return a written `.svg` at all — the response carries only the +/// descriptor, as JSON inside a text block. So an SVG export inlines its own +/// payload beside the descriptor, and the payload search has to step through +/// that JSON encoding to reach it. Before this, every SVG request failed with +/// "asset export response does not contain the requested binary" while PNG +/// worked, and the error said nothing about why. +#[test] +fn an_svg_payload_inlined_beside_the_descriptor_is_decoded_from_its_text() { + let svg = ""; + let bytes = svg.as_bytes(); + let sha256: String = sha2::Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let descriptor = json!({ + "kind": "devupAssetExport", "fileKey": "FileKey123", "version": "v1", + "assetId": "1:2:node", "nodeId": "1:2", "field": "node", + "imageHash": null, "format": "svg", "scale": 1, + "status": "exported", "byteLength": bytes.len(), "sha256": sha256, + "mimeType": "image/svg+xml", "text": svg, "errorCode": null + }); + // Exactly how it arrives: the descriptor serialized into a text block. + let result = UpstreamResult { + raw: json!({"content": [{"type": "text", "text": descriptor.to_string()}]}), + }; + let request = AssetRequest { + asset_id: "1:2:node".to_owned(), + node_id: "1:2".to_owned(), + field: "node".to_owned(), + image_hash: None, + format: AssetFormat::Svg, + scale: 1, + }; + + let entry = asset_export_from_result(&result, "FileKey123", Some("v1"), &request) + .expect("an inlined SVG payload must decode"); + + assert_eq!(entry.status, AssetStatus::Exported); + assert_eq!(entry.byte_length, Some(bytes.len())); + assert_eq!(entry.mime_type.as_deref(), Some("image/svg+xml")); + // Re-encoded to base64 so every consumer downstream is shape-independent. + let decoded = STANDARD + .decode(entry.data_base64.expect("payload").as_bytes()) + .expect("base64"); + assert_eq!(decoded, bytes); +} + +/// A response that carries no payload at all must say what it *did* carry, +/// so "nothing came back", "wrong mime type" and "unread field" stay +/// distinguishable instead of collapsing into one opaque sentence. +#[test] +fn a_missing_asset_payload_reports_the_shapes_that_were_present() { + let descriptor = json!({ + "kind": "devupAssetExport", "fileKey": "FileKey123", "version": "v1", + "assetId": "1:2:node", "nodeId": "1:2", "field": "node", + "imageHash": null, "format": "svg", "scale": 1, + "status": "exported", "byteLength": 10, "sha256": "00", "errorCode": null + }); + let result = UpstreamResult { + raw: json!({"content": [{"type": "text", "text": descriptor.to_string()}]}), + }; + let request = AssetRequest { + asset_id: "1:2:node".to_owned(), + node_id: "1:2".to_owned(), + field: "node".to_owned(), + image_hash: None, + format: AssetFormat::Svg, + scale: 1, + }; + + let error = asset_export_from_result(&result, "FileKey123", Some("v1"), &request) + .expect_err("no payload is an error"); + + assert_eq!(error.details["expectedMimeType"], "image/svg+xml"); + let observed = error.details["observed"].as_array().expect("observed"); + assert!( + observed.iter().any(|entry| entry + .as_str() + .unwrap_or_default() + .contains("carries=[text]")), + "the diagnostic must name the shapes that were present: {observed:?}" + ); +} + fn snapshot() -> Snapshot { Snapshot { file_key: "FileKey123".to_owned(), diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index f0a0442..856a1c9 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -996,7 +996,11 @@ impl ServerHandler for DevupServer { 5. In a handoff step, run the requested tool with the requested arguments exactly, and return the raw result unchanged via devup_figma_continue.\n\ 6. If a devup-mcp call fails, record it explicitly. Do not silently route around it.\n\ 7. Do not guess UI values such as color, spacing, radius, or typography. If you could not obtain them, stop and report.\n\ - 8. Do not implement a Section link as one whole subtree. Check the selection_required candidates and continue with per-screen export via frameIds or allScreens.", + 8. Do not implement a Section link as one whole subtree. Check the selection_required candidates and continue with per-screen export via frameIds or allScreens.\n\ + 9. The generated component name comes from the Figma layer name and is a starting point, not a contract. Rename it to fit the codebase, and rename a name that is meaningless or not a valid identifier.\n\ + 10. An asset path in the output, such as a maskImage or Image src, is a placeholder built from the layer name. Rename the file to fit the project. If the asset varies per usage, lift it into a prop instead of hardcoding it.\n\ + 11. A fixed asset such as an icon must actually be exported, never referenced by a path that does not exist yet. Read assetManifest for the asset IDs, then call devup_figma_export again with assetRequests, giving each entry an outputPath under an allowed write root, and make the path in the code match the path you wrote.\n\ + 12. Prefer delivery: \"resource\" for assets and large outputs. devup-mcp then returns devup://artifact/... resource links to read on demand instead of inlining bytes in every response.", ) } From e0d43d244cf56e48044e111893c8836e78b22744 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 20:20:25 +0900 Subject: [PATCH 29/69] fix(figma): discover an asset the way the Figma plugin does discover_asset_manifest registered only leaf VECTOR-ish nodes and every IMAGE fill, so the node the generated code actually references was never offered for export. Exporting the kakao icon returned a 2x2 fragment of one inner vector instead of the 20x20 icon, and the /icons/kakao-talk_2111496 1.svg path the code emits could not be produced at all. The plugin's checkAssetNode rules are now ported and the snapshot is walked top-down, stopping at the first node that classifies as an asset so a container wins over its fragments. Verified against the live file: the manifest yields exactly 3997:46298:node and exporting it produces the real 2071-byte icon whose sha256 matches the descriptor. The real-screen source map golden changes accordingly: nested instance leaf vectors such as I3879:35525;17:2032:node are replaced by their enclosing containers. --- ...uw_151__wquw_151_proofread_source_map.snap | 216 ++------------ crates/devup-mcp-figma/src/assets.rs | 272 ++++++++++++++---- crates/devup-mcp-figma/tests/assets.rs | 179 ++++++++++-- crates/devup-mcp/tests/composite_export.rs | 68 +++-- 4 files changed, 434 insertions(+), 301 deletions(-) diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap index 62a3654..c49e2c6 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_source_map.snap @@ -583,39 +583,9 @@ expression: output.source_map "start": 1035, "end": 1057 }, - "nodeId": "I3879:35525;17:2032", - "property": "node", - "assetId": "I3879:35525;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 1035, - "end": 1057 - }, - "nodeId": "I3879:35525;17:2034", - "property": "node", - "assetId": "I3879:35525;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 1035, - "end": 1057 - }, - "nodeId": "I3879:35525;17:2036", - "property": "node", - "assetId": "I3879:35525;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 1035, - "end": 1057 - }, - "nodeId": "I3879:35525;17:2038", + "nodeId": "3879:35525", "property": "node", - "assetId": "I3879:35525;17:2038:node", + "assetId": "3879:35525:node", "resolution": "asset" }, { @@ -1001,9 +971,9 @@ expression: output.source_map "start": 1620, "end": 1653 }, - "nodeId": "3879:35531", + "nodeId": "3879:35530", "property": "node", - "assetId": "3879:35531:node", + "assetId": "3879:35530:node", "resolution": "asset" }, { @@ -1387,9 +1357,9 @@ expression: output.source_map "start": 2292, "end": 2325 }, - "nodeId": "I3879:35534;20:3849", + "nodeId": "3879:35534", "property": "node", - "assetId": "I3879:35534;20:3849:node", + "assetId": "3879:35534:node", "resolution": "asset" }, { @@ -2034,9 +2004,9 @@ expression: output.source_map "start": 4044, "end": 4077 }, - "nodeId": "3879:35542", + "nodeId": "3879:35541", "property": "node", - "assetId": "3879:35542:node", + "assetId": "3879:35541:node", "resolution": "asset" }, { @@ -2358,39 +2328,9 @@ expression: output.source_map "start": 4746, "end": 4768 }, - "nodeId": "I3879:35545;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35545;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 4746, - "end": 4768 - }, - "nodeId": "I3879:35545;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35545;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 4746, - "end": 4768 - }, - "nodeId": "I3879:35545;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35545;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 4746, - "end": 4768 - }, - "nodeId": "I3879:35545;1690:32934;17:2038", + "nodeId": "I3879:35545;1690:32934", "property": "node", - "assetId": "I3879:35545;1690:32934;17:2038:node", + "assetId": "I3879:35545;1690:32934:node", "resolution": "asset" }, { @@ -2927,39 +2867,9 @@ expression: output.source_map "start": 5637, "end": 5659 }, - "nodeId": "I3879:35549;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35549;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 5637, - "end": 5659 - }, - "nodeId": "I3879:35549;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35549;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 5637, - "end": 5659 - }, - "nodeId": "I3879:35549;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35549;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 5637, - "end": 5659 - }, - "nodeId": "I3879:35549;1690:32934;17:2038", + "nodeId": "I3879:35549;1690:32934", "property": "node", - "assetId": "I3879:35549;1690:32934;17:2038:node", + "assetId": "I3879:35549;1690:32934:node", "resolution": "asset" }, { @@ -3505,39 +3415,9 @@ expression: output.source_map "start": 7248, "end": 7270 }, - "nodeId": "I3879:35553;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35553;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 7248, - "end": 7270 - }, - "nodeId": "I3879:35553;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35553;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 7248, - "end": 7270 - }, - "nodeId": "I3879:35553;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35553;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 7248, - "end": 7270 - }, - "nodeId": "I3879:35553;1690:32934;17:2038", + "nodeId": "I3879:35553;1690:32934", "property": "node", - "assetId": "I3879:35553;1690:32934;17:2038:node", + "assetId": "I3879:35553;1690:32934:node", "resolution": "asset" }, { @@ -4179,39 +4059,9 @@ expression: output.source_map "start": 9339, "end": 9361 }, - "nodeId": "I3879:35557;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35557;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 9339, - "end": 9361 - }, - "nodeId": "I3879:35557;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35557;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 9339, - "end": 9361 - }, - "nodeId": "I3879:35557;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35557;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 9339, - "end": 9361 - }, - "nodeId": "I3879:35557;1690:32934;17:2038", + "nodeId": "I3879:35557;1690:32934", "property": "node", - "assetId": "I3879:35557;1690:32934;17:2038:node", + "assetId": "I3879:35557;1690:32934:node", "resolution": "asset" }, { @@ -4757,39 +4607,9 @@ expression: output.source_map "start": 11592, "end": 11614 }, - "nodeId": "I3879:35561;1690:32934;17:2032", - "property": "node", - "assetId": "I3879:35561;1690:32934;17:2032:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 11592, - "end": 11614 - }, - "nodeId": "I3879:35561;1690:32934;17:2034", - "property": "node", - "assetId": "I3879:35561;1690:32934;17:2034:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 11592, - "end": 11614 - }, - "nodeId": "I3879:35561;1690:32934;17:2036", - "property": "node", - "assetId": "I3879:35561;1690:32934;17:2036:node", - "resolution": "asset" - }, - { - "generatedRange": { - "start": 11592, - "end": 11614 - }, - "nodeId": "I3879:35561;1690:32934;17:2038", + "nodeId": "I3879:35561;1690:32934", "property": "node", - "assetId": "I3879:35561;1690:32934;17:2038:node", + "assetId": "I3879:35561;1690:32934:node", "resolution": "asset" }, { diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index e75ea6a..8a87539 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use crate::{DevupError, Diagnostic, ErrorCode, Snapshot, UpstreamResult}; +use crate::{DevupError, Diagnostic, ErrorCode, RawNode, Snapshot, UpstreamResult}; pub const MAX_ASSET_BYTES: usize = 8 * 1024 * 1024; @@ -99,59 +99,37 @@ pub struct AssetManifest { pub diagnostics: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum AssetNode { + Svg, + Png { + fill_index: usize, + image_hash: Option, + }, +} + pub fn discover_asset_manifest(snapshot: &Snapshot) -> AssetManifest { let mut assets = Vec::new(); - for node in snapshot.nodes.values() { - if let Some(fills) = node.typed_view().value("fills").and_then(Value::as_array) { - for (index, fill) in fills.iter().enumerate() { - if fill.get("type").and_then(Value::as_str) != Some("IMAGE") { - continue; - } - let image_hash = fill - .get("imageHash") - .or_else(|| fill.get("imageRef")) - .and_then(Value::as_str) - .map(str::to_owned); - assets.push(AssetManifestEntry { - asset_id: format!("{}:fills:{index}", node.id), - node_id: node.id.clone(), - field: format!("fills/{index}"), - source_kind: "image-fill".to_owned(), - image_hash, - format: None, - scale: None, - status: AssetStatus::Available, - byte_length: None, - sha256: None, - mime_type: None, - data_base64: None, - output_path: None, - error_code: None, - }); - } + let mut pending = snapshot.roots.iter().rev().cloned().collect::>(); + let mut visited = std::collections::BTreeSet::new(); + + while let Some(node_id) = pending.pop() { + let Some(node) = snapshot.nodes.get(&node_id) else { + continue; + }; + if !visited.insert(node_id) { + continue; } - if matches!( - node.node_type.as_str(), - "VECTOR" | "BOOLEAN_OPERATION" | "STAR" | "LINE" | "ELLIPSE" | "POLYGON" - ) { - assets.push(AssetManifestEntry { - asset_id: format!("{}:node", node.id), - node_id: node.id.clone(), - field: "node".to_owned(), - source_kind: "vector-node".to_owned(), - image_hash: None, - format: None, - scale: None, - status: AssetStatus::Available, - byte_length: None, - sha256: None, - mime_type: None, - data_base64: None, - output_path: None, - error_code: None, - }); + + if let Some(asset) = compute_asset_node(snapshot, node, false) { + assets.push(manifest_entry(node, asset)); + continue; } + + let child_ids = node.typed_view().child_ids().collect::>(); + pending.extend(child_ids.into_iter().rev().map(str::to_owned)); } + assets.sort_by(|left, right| left.asset_id.cmp(&right.asset_id)); AssetManifest { version: 1, @@ -160,6 +138,202 @@ pub fn discover_asset_manifest(snapshot: &Snapshot) -> AssetManifest { } } +fn compute_asset_node(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Option { + let view = node.typed_view(); + if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") + || view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + { + return None; + } + + if has_smart_animate_reaction(node) + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(has_smart_animate_reaction) + { + return None; + } + + if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + return Some(AssetNode::Svg); + } + + if view.node_type() == "ELLIPSE" + && view + .value("arcData") + .and_then(|arc_data| arc_data.get("innerRadius")) + .and_then(Value::as_f64) + .is_some_and(|inner_radius| inner_radius != 0.0) + { + return Some(AssetNode::Svg); + } + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + return compute_leaf_asset(node, nested); + } + + if child_ids.len() == 1 { + if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] + .into_iter() + .any(|field| view.number(field).is_some_and(|padding| padding > 0.0)) + || fills(node).is_some_and(|fills| fills.iter().any(is_visible_fill)) + { + return None; + } + + return snapshot + .nodes + .get(child_ids[0]) + .and_then(|child| compute_asset_node(snapshot, child, true)); + } + + let mut visible_children = Vec::new(); + for child_id in child_ids { + let child = snapshot.nodes.get(child_id)?; + if child.typed_view().bool("visible") != Some(false) { + visible_children.push(child); + } + } + + visible_children + .into_iter() + .all(|child| compute_asset_node(snapshot, child, true) == Some(AssetNode::Svg)) + .then_some(AssetNode::Svg) +} + +fn compute_leaf_asset(node: &RawNode, nested: bool) -> Option { + let node_fills = fills(node); + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && (fill_type(fill) == Some("PATTERN") + || (fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) == Some("TILE"))) + }) + }) { + return None; + } + + if node.typed_view().bool("isAsset") == Some(true) { + if let Some((fill_index, fill)) = node_fills.and_then(|fills| { + fills.iter().enumerate().find(|(_, fill)| { + is_visible_fill(fill) + && fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") + }) + }) { + if node_fills.is_some_and(|fills| fills.len() == 1) { + return Some(AssetNode::Png { + fill_index, + image_hash: fill + .get("imageHash") + .or_else(|| fill.get("imageRef")) + .and_then(Value::as_str) + .map(str::to_owned), + }); + } + return None; + } + + if node_fills.is_none_or(|fills| { + fills + .iter() + .all(|fill| is_visible_fill(fill) && fill_type(fill) == Some("SOLID")) + }) { + return nested.then_some(AssetNode::Svg); + } + + return Some(AssetNode::Svg); + } + + (nested + && node_fills.is_some_and(|fills| { + fills.iter().all(|fill| { + !is_visible_fill(fill) + || !matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) + }) + })) + .then_some(AssetNode::Svg) +} + +fn fills(node: &RawNode) -> Option<&Vec> { + node.typed_view().value("fills").and_then(Value::as_array) +} + +fn fill_type(fill: &Value) -> Option<&str> { + fill.get("type").and_then(Value::as_str) +} + +fn is_visible_fill(fill: &Value) -> bool { + fill.get("visible").and_then(Value::as_bool) != Some(false) +} + +fn has_smart_animate_reaction(node: &RawNode) -> bool { + node.typed_view() + .value("reactions") + .and_then(Value::as_array) + .is_some_and(|reactions| { + reactions.iter().any(|reaction| { + reaction + .get("actions") + .and_then(Value::as_array) + .is_some_and(|actions| { + actions.iter().any(|action| { + action.get("type").and_then(Value::as_str) == Some("NODE") + && action + .get("transition") + .and_then(|transition| transition.get("type")) + .and_then(Value::as_str) + == Some("SMART_ANIMATE") + }) + }) + }) + }) +} + +fn manifest_entry(node: &RawNode, asset: AssetNode) -> AssetManifestEntry { + let (asset_id, field, source_kind, image_hash) = match asset { + AssetNode::Svg => ( + format!("{}:node", node.id), + "node".to_owned(), + "vector-node".to_owned(), + None, + ), + AssetNode::Png { + fill_index, + image_hash, + } => ( + format!("{}:fills:{fill_index}", node.id), + format!("fills/{fill_index}"), + "image-fill".to_owned(), + image_hash, + ), + }; + + AssetManifestEntry { + asset_id, + node_id: node.id.clone(), + field, + source_kind, + image_hash, + format: None, + scale: None, + status: AssetStatus::Available, + byte_length: None, + sha256: None, + mime_type: None, + data_base64: None, + output_path: None, + error_code: None, + } +} + pub fn validate_asset_requests( snapshot: &Snapshot, requests: &[AssetRequest], diff --git a/crates/devup-mcp-figma/tests/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index 7950197..ca7e079 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -25,7 +25,7 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-1").unwrap(); let mut request = CollectionRequest::new(target, CollectionScope::Node); request.asset_selections = vec![AssetSelection { - asset_id: "1:1:fills:1".to_owned(), + asset_id: "1:1:fills:0".to_owned(), format: AssetFormat::Png, scale: 2, }]; @@ -68,14 +68,14 @@ fn collector_exports_only_explicit_assets_and_preserves_snapshot_on_export_failu let ReadToolCall::AssetExport { request, .. } = asset_call.call else { panic!("asset export call") }; - assert_eq!(request.asset_id, "1:1:fills:1"); + assert_eq!(request.asset_id, "1:1:fills:0"); collector .accept( &asset_call.id, UpstreamResult { raw: json!({ "kind":"devupAssetExport","fileKey":"FileKey123","version":"v1", - "assetId":"1:1:fills:1","nodeId":"1:1","field":"fills/1", + "assetId":"1:1:fills:0","nodeId":"1:1","field":"fills/0", "imageHash":"image-hash-123","format":"png","scale":2, "status":"failed","byteLength":null,"sha256":null, "errorCode":"DEVUP_ASSET_EXPORT_FAILED" @@ -187,24 +187,15 @@ fn snapshot() -> Snapshot { file_key: "FileKey123".to_owned(), version: Some("v1".to_owned()), roots: vec!["1:1".to_owned()], - nodes: [ - node( - "1:1", - "FRAME", - json!({ - "childrenIds": ["1:2"], - "fills": [ - {"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}, - {"type": "IMAGE", "imageHash": "image-hash-123", "scaleMode": "FILL"} - ] - }), - ), - node( - "1:2", - "VECTOR", - json!({"parentId": "1:1", "childrenIds": [], "fills": []}), - ), - ] + nodes: [node( + "1:1", + "FRAME", + json!({ + "childrenIds": [], + "isAsset": true, + "fills": [{"type": "IMAGE", "imageHash": "image-hash-123", "scaleMode": "FILL"}] + }), + )] .into_iter() .map(|node| (node.id.clone(), node)) .collect(), @@ -217,19 +208,153 @@ fn manifest_preserves_image_and_vector_source_details_without_exporting_bytes() let manifest = discover_asset_manifest(&snapshot()); assert_eq!(manifest.version, 1); - assert_eq!(manifest.assets.len(), 2); - assert_eq!(manifest.assets[0].asset_id, "1:1:fills:1"); + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "1:1:fills:0"); assert_eq!(manifest.assets[0].node_id, "1:1"); - assert_eq!(manifest.assets[0].field, "fills/1"); + assert_eq!(manifest.assets[0].field, "fills/0"); assert_eq!(manifest.assets[0].source_kind, "image-fill"); assert_eq!( manifest.assets[0].image_hash.as_deref(), Some("image-hash-123") ); assert_eq!(manifest.assets[0].status, AssetStatus::Available); - assert_eq!(manifest.assets[1].asset_id, "1:2:node"); - assert_eq!(manifest.assets[1].source_kind, "vector-node"); - assert!(manifest.assets[1].data_base64.is_none()); + assert!(manifest.assets[0].data_base64.is_none()); +} + +fn manifest_for(roots: &[&str], nodes: Vec) -> devup_mcp_figma::AssetManifest { + discover_asset_manifest(&Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: roots.iter().map(|root| (*root).to_owned()).collect(), + nodes: nodes + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }) +} + +#[test] +fn icon_container_wins_over_its_vector_fragments() { + let manifest = manifest_for( + &["3997:46297"], + vec![ + node( + "3997:46297", + "FRAME", + json!({"name": "input", "childrenIds": ["3997:46298", "3997:46301"]}), + ), + node( + "3997:46298", + "FRAME", + json!({ + "name": "kakao-talk_2111496 1", + "parentId": "3997:46297", + "isAsset": true, + "childrenIds": ["3997:46299", "3997:46300"] + }), + ), + node( + "3997:46299", + "VECTOR", + json!({"name": "Vector", "parentId": "3997:46298"}), + ), + node( + "3997:46300", + "VECTOR", + json!({"name": "Vector", "parentId": "3997:46298"}), + ), + node( + "3997:46301", + "TEXT", + json!({"name": "카카오로 공유하기", "parentId": "3997:46297"}), + ), + ], + ); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "3997:46298:node"); + assert_eq!(manifest.assets[0].node_id, "3997:46298"); + assert_eq!(manifest.assets[0].field, "node"); + assert_eq!(manifest.assets[0].source_kind, "vector-node"); + assert_eq!(manifest.assets[0].image_hash, None); +} + +#[test] +fn bare_vector_is_an_svg_asset_but_text_is_not() { + let manifest = manifest_for( + &["1:vector", "1:text"], + vec![ + node("1:vector", "VECTOR", json!({})), + node("1:text", "TEXT", json!({})), + ], + ); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "1:vector:node"); + assert_eq!(manifest.assets[0].source_kind, "vector-node"); +} + +#[test] +fn decorated_single_child_containers_do_not_replace_their_children() { + let manifest = manifest_for( + &["1:padding", "1:filled"], + vec![ + node( + "1:padding", + "FRAME", + json!({"childrenIds": ["1:padding-vector"], "paddingLeft": 8}), + ), + node( + "1:padding-vector", + "VECTOR", + json!({"parentId": "1:padding"}), + ), + node( + "1:filled", + "FRAME", + json!({ + "childrenIds": ["1:filled-vector"], + "fills": [{"type": "SOLID", "visible": true}] + }), + ), + node("1:filled-vector", "VECTOR", json!({"parentId": "1:filled"})), + ], + ); + + let asset_ids = manifest + .assets + .iter() + .map(|asset| asset.asset_id.as_str()) + .collect::>(); + assert_eq!( + asset_ids, + vec!["1:filled-vector:node", "1:padding-vector:node"] + ); +} + +#[test] +fn asset_leaf_with_one_non_tiled_image_fill_is_a_png_asset() { + let manifest = manifest_for( + &["1:image"], + vec![node( + "1:image", + "RECTANGLE", + json!({ + "isAsset": true, + "fills": [{"type": "IMAGE", "scaleMode": "FILL", "imageRef": "image-ref-123"}] + }), + )], + ); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].asset_id, "1:image:fills:0"); + assert_eq!(manifest.assets[0].field, "fills/0"); + assert_eq!(manifest.assets[0].source_kind, "image-fill"); + assert_eq!( + manifest.assets[0].image_hash.as_deref(), + Some("image-ref-123") + ); } #[test] diff --git a/crates/devup-mcp/tests/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index 636fd75..88110ab 100644 --- a/crates/devup-mcp/tests/composite_export.rs +++ b/crates/devup-mcp/tests/composite_export.rs @@ -250,7 +250,7 @@ async fn one_acquisition_projects_all_outputs_and_artifact_reuse_is_zero_call() assert_eq!(first["sourceMap"]["version"], 1); assert_eq!( first["assetManifest"]["assets"][0]["assetId"], - "1:2:fills:1" + "1:3:fills:0" ); assert_eq!(first["assetManifest"]["assets"][0]["status"], "available"); assert!(first["sourceMap"]["tsx"].as_array().is_some_and(|entries| { @@ -412,7 +412,7 @@ async fn explicit_asset_request_exports_once_and_returns_validated_binary() -> a "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", "outputs": ["tsx", "assetManifest"], "sourcePolicy": "direct", - "assetRequests": [{"assetId":"1:2:fills:1","format":"png","scale":2}] + "assetRequests": [{"assetId":"1:3:fills:0","format":"png","scale":2}] }), ) .await?; @@ -455,7 +455,7 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> "sourcePolicy": "direct", "delivery": "resource", "assetRequests": [{ - "assetId":"1:2:fills:1", + "assetId":"1:3:fills:0", "format":"png", "scale":2, "outputPath": output_path.to_string_lossy() @@ -527,7 +527,7 @@ async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> "sourcePolicy": "direct", "delivery": "resource", "assetRequests": [{ - "assetId":"1:2:fills:1", + "assetId":"1:3:fills:0", "format":"png", "scale":2, "outputPath": output_path.to_string_lossy() @@ -633,18 +633,18 @@ async fn artifact_reuse_rejects_a_different_asset_format_or_scale() -> anyhow::R "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", "outputs": ["assetManifest"], "sourcePolicy": "direct", - "assetRequests": [{"assetId":"1:2:fills:1","format":"png","scale":2}] + "assetRequests": [{"assetId":"1:3:fills:0","format":"png","scale":2}] }), ) .await?; let artifact_id = acquired["cache"]["artifactId"].as_str().unwrap(); assert_eq!(acquired["cache"]["capabilities"]["assetCaptureCount"], 1); - assert!(!serde_json::to_string(&acquired["cache"]["capabilities"])?.contains("1:2:fills:1")); + assert!(!serde_json::to_string(&acquired["cache"]["capabilities"])?.contains("1:3:fills:0")); assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); for request in [ - json!({"assetId":"1:2:fills:1","format":"svg","scale":2}), - json!({"assetId":"1:2:fills:1","format":"png","scale":1}), + json!({"assetId":"1:3:fills:0","format":"svg","scale":2}), + json!({"assetId":"1:3:fills:0","format":"png","scale":1}), ] { let reused = client .call_tool( @@ -772,25 +772,39 @@ fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], - "nodes": [{ - "id": "1:2", - "type": "FRAME", - "fields": { - "name": "Synthetic", - "childrenIds": [], - "layoutMode": "VERTICAL", - "width": 320, - "height": 240, - "fills": [{ - "type": "SOLID", - "color": {"r": 0, "g": 0.4, "b": 1, "a": 1}, - "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "v"}} - }, {"type":"IMAGE","imageHash":"image-hash-123","scaleMode":"FILL"}], - "boundVariables": {"fills": [{"type": "VARIABLE_ALIAS", "id": "v"}]} + "nodes": [ + { + "id": "1:2", + "type": "FRAME", + "fields": { + "name": "Synthetic", + "childrenIds": ["1:3"], + "layoutMode": "VERTICAL", + "width": 320, + "height": 240, + "fills": [{ + "type": "SOLID", + "color": {"r": 0, "g": 0.4, "b": 1, "a": 1}, + "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "v"}} + }, {"type":"IMAGE","imageHash":"image-hash-123","scaleMode":"FILL"}], + "boundVariables": {"fills": [{"type": "VARIABLE_ALIAS", "id": "v"}]} + }, + "extra": {}, + "fieldErrors": {} }, - "extra": {}, - "fieldErrors": {} - }], + { + "id": "1:3", + "type": "RECTANGLE", + "fields": { + "name": "Synthetic asset", + "parentId": "1:2", + "isAsset": true, + "fills": [{"type":"IMAGE","imageHash":"image-hash-123","scaleMode":"FILL"}] + }, + "extra": {}, + "fieldErrors": {} + } + ], "diagnostics": [] }, "resources": { @@ -812,7 +826,7 @@ fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { "unresolved": [] }, "integrity": { - "nodeCount": 1, + "nodeCount": 2, "variableRefCount": 1, "styleRefCount": 0, "utf8Bytes": 0 From ced186d50ede10ec6d00269358cc6420a82a314a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 20:20:25 +0900 Subject: [PATCH 30/69] fix(devup-ui): stop flattening a container with text into a single image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset boundary was decided twice — once for the manifest, once in codegen — and only the manifest was corrected. codegen/style.rs::asset_kind ran its isAsset branch before looking at children, so a frame carrying an image fill AND text children returned Png and its children were discarded. Converting a real screen silently deleted two Text nodes and a logo from the book-cover preview. asset_kind and asset_kind_nested now follow the same checkAssetNode rules as the manifest: the isAsset paint rules apply to leaves only, the single-child rule keys on padding and visible fills rather than layoutMode, and SMART_ANIMATE targets are excluded. SvgMask selection via uniform_asset_color is unchanged. The 268-case upstream parity corpus still passes byte-for-byte, which is the evidence the port is faithful; no upstream golden was touched. That corpus contains zero cases where an isAsset node has a TEXT child, which is why it stayed green while the bug was real, so tests/asset_boundaries.rs adds that shape and six neighbouring ones. Re-converting the screen restores text 23/23 and reproduces the plugin's own header and book-cover output. --- .../devup-mcp-devup-ui/src/codegen/style.rs | 248 +++++++++++------- .../tests/asset_boundaries.rs | 240 +++++++++++++++++ ...151_frames__wquw_151_frame_3879_36059.snap | 22 +- ...151_frames__wquw_151_frame_3879_36108.snap | 22 +- 4 files changed, 406 insertions(+), 126 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/asset_boundaries.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index 630dd01..6f24106 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -16,129 +16,173 @@ pub(super) enum AssetKind { } pub(super) fn asset_kind(snapshot: &Snapshot, node: &RawNode) -> Option { + asset_kind_nested(snapshot, node, false) +} + +fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Option { let view = node.typed_view(); - if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") { + if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") + || view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + { return None; } - if view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(Value::as_str) - == Some("GRID") + + if has_smart_animate_reaction(node) + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(has_smart_animate_reaction) { return None; } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") - || (view.node_type() == "ELLIPSE" - && view - .value("arcData") - .and_then(|value| value.get("innerRadius")) - .and_then(Value::as_f64) - .is_some_and(|value| value != 0.0)) + + if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + return Some(svg_asset_kind(snapshot, node)); + } + + if view.node_type() == "ELLIPSE" + && view + .value("arcData") + .and_then(|arc_data| arc_data.get("innerRadius")) + .and_then(Value::as_f64) + .is_some_and(|inner_radius| inner_radius != 0.0) { - return Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }); + return Some(svg_asset_kind(snapshot, node)); } - let fills = view.value("fills").and_then(Value::as_array); - if view.bool("isAsset") == Some(true) { - if fills.is_some_and(|fills| { - fills.len() == 1 - && fills[0].get("type").and_then(Value::as_str) == Some("IMAGE") - && fills[0].get("scaleMode").and_then(Value::as_str) != Some("TILE") - }) { - return Some(AssetKind::Png); - } - if fills.is_some_and(|fills| { - !fills.is_empty() - && !fills.iter().all(|paint| { - paint.get("type").and_then(Value::as_str) == Some("SOLID") - && paint.get("visible").and_then(Value::as_bool) == Some(true) - }) - }) { - return Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }); - } + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + return leaf_asset_kind(snapshot, node, nested); } - if view.child_ids().next().is_some() { - let children = view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .collect::>(); - let direct_vectors = children.iter().all(|child| { - matches!( - child.typed_view().node_type(), - "VECTOR" | "STAR" | "POLYGON" - ) - }); - if view.bool("isAsset") == Some(true) && first_solid_color(view.value("fills")).is_some() { - return None; - } - if children.len() == 1 - && !direct_vectors - && matches!( - view.string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - ) + + if child_ids.len() == 1 { + if ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"] + .into_iter() + .any(|field| view.number(field).is_some_and(|padding| padding > 0.0)) + || fills(node).is_some_and(|fills| fills.iter().any(is_visible_fill)) { return None; } - if !children.is_empty() - && children.iter().all(|child| { - matches!( - asset_kind_nested(snapshot, child), - Some(AssetKind::Svg | AssetKind::SvgMask) - ) - }) + + return match snapshot + .nodes + .get(child_ids[0]) + .and_then(|child| asset_kind_nested(snapshot, child, true)) { - return Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg - }); + Some(AssetKind::Png) => Some(AssetKind::Png), + Some(AssetKind::Svg | AssetKind::SvgMask) => Some(svg_asset_kind(snapshot, node)), + None => None, + }; + } + + let mut visible_children = Vec::new(); + for child_id in child_ids { + let child = snapshot.nodes.get(child_id)?; + if child.typed_view().bool("visible") != Some(false) { + visible_children.push(child); } } - None + + visible_children + .into_iter() + .all(|child| { + matches!( + asset_kind_nested(snapshot, child, true), + Some(AssetKind::Svg | AssetKind::SvgMask) + ) + }) + .then(|| svg_asset_kind(snapshot, node)) } -fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode) -> Option { - if let Some(kind) = asset_kind(snapshot, node) { - return Some(kind); - } - let view = node.typed_view(); - if view.node_type() == "TEXT" { - return None; - } - if view.child_ids().next().is_some() { - return None; - } - let fills = view.value("fills").and_then(Value::as_array)?; - if fills.iter().any(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) != Some("SOLID") +fn leaf_asset_kind(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Option { + let node_fills = fills(node); + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && (fill_type(fill) == Some("PATTERN") + || (fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) == Some("TILE"))) + }) }) { return None; } - if fills.iter().any(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && matches!( - paint.get("type").and_then(Value::as_str), - Some("IMAGE" | "VIDEO" | "PATTERN") - ) - }) { - None - } else { - Some(if uniform_asset_color(snapshot, node).is_some() { - AssetKind::SvgMask - } else { - AssetKind::Svg + + if node.typed_view().bool("isAsset") == Some(true) { + if node_fills.is_some_and(|fills| { + fills.iter().any(|fill| { + is_visible_fill(fill) + && fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") + }) + }) { + return (node_fills.is_some_and(|fills| fills.len() == 1)).then_some(AssetKind::Png); + } + + if node_fills.is_none_or(|fills| { + fills + .iter() + .all(|fill| is_visible_fill(fill) && fill_type(fill) == Some("SOLID")) + }) { + return nested.then(|| svg_asset_kind(snapshot, node)); + } + + return Some(svg_asset_kind(snapshot, node)); + } + + (nested + && node_fills.is_some_and(|fills| { + fills.iter().all(|fill| { + !is_visible_fill(fill) + || !matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) + }) + })) + .then(|| svg_asset_kind(snapshot, node)) +} + +fn fills(node: &RawNode) -> Option<&Vec> { + node.typed_view().value("fills").and_then(Value::as_array) +} + +fn fill_type(fill: &Value) -> Option<&str> { + fill.get("type").and_then(Value::as_str) +} + +fn is_visible_fill(fill: &Value) -> bool { + fill.get("visible").and_then(Value::as_bool) != Some(false) +} + +fn has_smart_animate_reaction(node: &RawNode) -> bool { + node.typed_view() + .value("reactions") + .and_then(Value::as_array) + .is_some_and(|reactions| { + reactions.iter().any(|reaction| { + reaction + .get("actions") + .and_then(Value::as_array) + .is_some_and(|actions| { + actions.iter().any(|action| { + action.get("type").and_then(Value::as_str) == Some("NODE") + && action + .get("transition") + .and_then(|transition| transition.get("type")) + .and_then(Value::as_str) + == Some("SMART_ANIMATE") + }) + }) + }) }) +} + +fn svg_asset_kind(snapshot: &Snapshot, node: &RawNode) -> AssetKind { + if uniform_asset_color(snapshot, node).is_some() { + AssetKind::SvgMask + } else { + AssetKind::Svg } } diff --git a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs new file mode 100644 index 0000000..21a86fc --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -0,0 +1,240 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +#[test] +fn image_filled_asset_container_preserves_text_children() { + let tsx = generate( + "1:cover", + json!([ + { + "id": "1:cover", "type": "FRAME", + "fields": { + "name": "Book cover", "childrenIds": ["1:title"], "isAsset": true, + "fills": [{"type": "IMAGE", "visible": true, "scaleMode": "FILL"}] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:title", "type": "TEXT", + "fields": { + "name": "Title", "parentId": "1:cover", "childrenIds": [], + "characters": "Preserved title" + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + assert!(tsx.contains("bg=\"url(/icons/image.png) center/cover no-repeat\"")); + assert!(tsx.contains("Preserved title")); + assert!(!tsx.contains(" -
- -
+
-
- -
+
Date: Thu, 3 Sep 2026 21:15:26 +0900 Subject: [PATCH 31/69] fix(devup-ui): count layout coverage against the real asset boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset decision existed in three places. Two were unified with the Figma plugin's checkAssetNode today; provenance.rs::projects_as_asset was the third and still ran its own older rules, so the fidelity report did not recognise a boundary codegen had just folded. Fields inside that subtree were then counted as unrepresented even though they can never appear in the output, producing a permanent shortfall no change could ever close. projects_as_asset now delegates to codegen::asset_kind rather than restating the rules, and the layout set drops the fields that describe how an asset's hidden children were arranged (layoutMode, itemSpacing, the four padding sides) while keeping the geometry that does reach the emitted element (width, height, layoutPositioning). Measured against the live file: 3997-48764 goes 66/69 -> 66/66 and 3997-46156 goes 86/89 -> 86/86, both keeping status complete, projection exact and deliverable.isFinal. 3997-45722 goes 113/133 -> 124/128, and the four that remain are genuine — the outer width/height of two folded nodes really are absent from the generated TSX. FidelityReport also gains uncoveredLayout, listing the nodeId#property pairs behind a shortfall. A ratio alone could not distinguish a wrong layout from one merely expressed differently, which is why this defect survived so long. It is informational only and feeds neither impacts nor status. --- crates/devup-mcp-devup-ui/src/codegen/mod.rs | 1 + .../devup-mcp-devup-ui/src/codegen/style.rs | 4 +- crates/devup-mcp-devup-ui/src/provenance.rs | 138 +++++++----------- crates/devup-mcp-devup-ui/tests/provenance.rs | 82 +++++++++++ 4 files changed, 135 insertions(+), 90 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index 60336a5..27236bc 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -15,3 +15,4 @@ pub use component::{ generate_inlined_component_instance, generate_legacy_component, generate_node, normalize_component_name, render_component_registration_snapshot, render_component_source, }; +pub(crate) use style::asset_kind; diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index 6f24106..eeb8f7c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -9,13 +9,13 @@ use super::{ }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AssetKind { +pub(crate) enum AssetKind { Svg, SvgMask, Png, } -pub(super) fn asset_kind(snapshot: &Snapshot, node: &RawNode) -> Option { +pub(crate) fn asset_kind(snapshot: &Snapshot, node: &RawNode) -> Option { asset_kind_nested(snapshot, node, false) } diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index dc75b88..94357a1 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -4,7 +4,7 @@ use devup_mcp_figma::{DevupError, ErrorCode, FidelityImpact, Snapshot, discover_ use serde::{Deserialize, Serialize}; use serde_json::json; -use crate::codegen::CodegenOutput; +use crate::codegen::{CodegenOutput, asset_kind}; const START: &str = "\u{e000}DEVUP_PROVENANCE_START:"; const END: &str = "\u{e000}DEVUP_PROVENANCE_END:"; @@ -125,8 +125,19 @@ pub struct FidelityReport { pub assets: FidelityCoverage, pub layout: FidelityCoverage, pub impacts: FidelityImpactCounts, + /// The `nodeId#property` layout pairs the generated TSX does not account + /// for, bounded by [`MAX_REPORTED_UNCOVERED`]. Reporting only a ratio left + /// a shortfall untriageable: nothing said whether the layout was wrong or + /// merely expressed another way. Purely informational — it does not feed + /// `impacts`, `strict_compatible`, or the reported status. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uncovered_layout: Vec, } +/// Enough to see the shape of a shortfall without turning a diagnostic into a +/// second payload. +const MAX_REPORTED_UNCOVERED: usize = 40; + impl FidelityReport { pub fn strict_compatible(&self) -> bool { self.syntax_valid @@ -388,26 +399,41 @@ pub fn validate_fidelity( .iter() .filter(|node_id| !has_asset_ancestor(node_id, &parents, &asset_nodes)) .flat_map(|node_id| { - LAYOUT_FIELDS.iter().filter_map(|field| { + let is_asset = asset_nodes.contains(*node_id); + LAYOUT_FIELDS.iter().filter_map(move |field| { snapshot .nodes .get(*node_id) - .filter(|node| layout_field_is_semantic(snapshot, node, field)) + .filter(|node| { + (!is_asset || !asset_layout_field_is_internal(field)) + && layout_field_is_semantic(snapshot, node, field) + }) .map(|_| ((*node_id).to_owned(), (*field).to_owned())) }) }) .collect::>(); - let covered_layout = layout - .iter() - .filter(|(node_id, property)| { - output.source_map.entries.iter().any(|entry| { + let (covered_layout, uncovered_layout) = { + let mut covered = 0usize; + // Which pairs were not represented, not just how many. A count alone + // cannot distinguish "the layout is wrong" from "the same layout is + // expressed differently", so a shortfall was previously impossible to + // act on or even to triage. + let mut uncovered = Vec::new(); + for (node_id, property) in &layout { + let represented = output.source_map.entries.iter().any(|entry| { entry.node_id.as_deref() == Some(node_id.as_str()) && entry.property.as_deref() == Some(property.as_str()) && entry_range(entry, &output.tsx) .is_some_and(|source| layout_source_matches(property, source)) - }) - }) - .count(); + }); + if represented { + covered += 1; + } else if uncovered.len() < MAX_REPORTED_UNCOVERED { + uncovered.push(format!("{node_id}#{property}")); + } + } + (covered, uncovered) + }; let mut impacts = FidelityImpactCounts::default(); for diagnostic in &output.diagnostics { match diagnostic.fidelity_impact() { @@ -426,6 +452,7 @@ pub fn validate_fidelity( assets: FidelityCoverage::new(assets.len(), covered_assets), layout: FidelityCoverage::new(layout.len(), covered_layout), impacts, + uncovered_layout, }) } @@ -444,6 +471,18 @@ fn has_asset_ancestor( false } +fn asset_layout_field_is_internal(field: &str) -> bool { + matches!( + field, + "layoutMode" + | "itemSpacing" + | "paddingTop" + | "paddingRight" + | "paddingBottom" + | "paddingLeft" + ) +} + fn layout_field_is_semantic( snapshot: &Snapshot, node: &devup_mcp_figma::RawNode, @@ -494,84 +533,7 @@ fn layout_field_is_semantic( } fn projects_as_asset(snapshot: &Snapshot, node: &devup_mcp_figma::RawNode) -> bool { - fn nested(snapshot: &Snapshot, node: &devup_mcp_figma::RawNode) -> bool { - if projects_as_asset(snapshot, node) { - return true; - } - let view = node.typed_view(); - if view.node_type() == "TEXT" || view.child_ids().next().is_some() { - return false; - } - view.value("fills") - .and_then(serde_json::Value::as_array) - .is_some_and(|fills| { - fills.iter().all(|paint| { - paint.get("visible").and_then(serde_json::Value::as_bool) == Some(false) - || paint.get("type").and_then(serde_json::Value::as_str) == Some("SOLID") - }) - }) - } - - let view = node.typed_view(); - if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") - || view - .value("inferredAutoLayout") - .and_then(serde_json::Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(serde_json::Value::as_str) - == Some("GRID") - { - return false; - } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") - || (view.node_type() == "ELLIPSE" - && view - .value("arcData") - .and_then(|value| value.get("innerRadius")) - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| value != 0.0)) - { - return true; - } - let fills = view.value("fills").and_then(serde_json::Value::as_array); - if view.bool("isAsset") == Some(true) - && fills.is_some_and(|fills| { - (fills.len() == 1 - && fills[0].get("type").and_then(serde_json::Value::as_str) == Some("IMAGE") - && fills[0] - .get("scaleMode") - .and_then(serde_json::Value::as_str) - != Some("TILE")) - || (!fills.is_empty() - && !fills.iter().all(|paint| { - paint.get("type").and_then(serde_json::Value::as_str) == Some("SOLID") - && paint.get("visible").and_then(serde_json::Value::as_bool) - == Some(true) - })) - }) - { - return true; - } - let children = view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .collect::>(); - if children.is_empty() - || (children.len() == 1 - && !children.iter().all(|child| { - matches!( - child.typed_view().node_type(), - "VECTOR" | "STAR" | "POLYGON" - ) - }) - && matches!( - view.string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - )) - { - return false; - } - children.into_iter().all(|child| nested(snapshot, child)) + asset_kind(snapshot, node).is_some() } fn semantic_nodes<'a>(snapshot: &'a Snapshot, root_id: &str) -> BTreeSet<&'a str> { diff --git a/crates/devup-mcp-devup-ui/tests/provenance.rs b/crates/devup-mcp-devup-ui/tests/provenance.rs index e3b94da..c377ffe 100644 --- a/crates/devup-mcp-devup-ui/tests/provenance.rs +++ b/crates/devup-mcp-devup-ui/tests/provenance.rs @@ -318,6 +318,88 @@ fn strict_fidelity_requires_layout_property_mappings_not_only_node_trace() { assert!(!report.strict_compatible()); } +#[test] +fn asset_boundaries_exclude_internal_and_descendant_layout_fields() { + let snapshot = Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["root".to_owned()], + nodes: [ + node( + "root", + "FRAME", + json!({ + "name": "Host", "childrenIds": ["asset"], + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}] + }), + ), + node( + "asset", + "FRAME", + json!({ + "name": "Folded icon", "parentId": "root", + "childrenIds": ["glyph-left", "glyph-right"], + "layoutMode": "HORIZONTAL", "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "itemSpacing": 4, "paddingTop": 1, "paddingRight": 2, + "paddingBottom": 3, "paddingLeft": 4, + "width": 24, "height": 24, "x": 0, "y": 0 + }), + ), + node( + "glyph-left", + "FRAME", + json!({ + "name": "Left glyph", "parentId": "asset", "childrenIds": [], + "isAsset": true, "width": 10, "height": 20 + }), + ), + node( + "glyph-right", + "FRAME", + json!({ + "name": "Right glyph", "parentId": "asset", "childrenIds": [], + "isAsset": true, "width": 10, "height": 20 + }), + ), + ] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }; + + let output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + + assert!(output.tsx.contains(" Date: Thu, 3 Sep 2026 21:54:38 +0900 Subject: [PATCH 32/69] fix(devup-ui): keep the pinned size of absolutely positioned nodes An absolutely positioned node is out of flow, so nothing constrains it from outside. The layout pass nevertheless left such nodes sizeless when they had children, expecting the children to define the box. That is wrong whenever Figma pinned the size, and it broke two different ways on the same screen: a node folded into a single asset has no children left to measure, so its mask box collapsed to zero and the artwork never rendered; and a 20x20 circular container whose only child is 14.29px shrank to the child, taking its border radius and overflow clip with it. Restating the fixed dimensions covers both. The rule needs no reference to assets: the earlier attempts keyed on targetAspectRatio being present, then on the node being an asset, and each only happened to fit one of the two cases. Measured against the live file: the BI logo box now carries w=24px h=9px next to its aspectRatio, the checkbox container carries boxSize=20px, and 3997-45722 goes from layout 124/128 to 128/128 with nothing left uncovered. 3997-48764, 3997-46156, 3997-46232 and 3997-46311 stay fully covered and keep status complete, projection exact and deliverable.isFinal. All 268 upstream plugin goldens remain byte-identical, which is what rules out the simpler rule being too broad. --- .../devup-mcp-devup-ui/src/codegen/layout.rs | 10 +++ .../tests/folded_asset_size.rs | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 crates/devup-mcp-devup-ui/tests/folded_asset_size.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index f194575..ae5b6f5 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -74,6 +74,16 @@ pub(super) fn push_layout_props( }; height = Some("100%".to_owned()); } + // An absolutely positioned node is out of flow, so nothing constrains + // it from the outside and the branches above may leave it sizeless, + // expecting its children to define the box. That is wrong whenever + // Figma pinned the size: a folded asset has no children left to + // measure, and a container whose children are smaller than the frame + // shrinks to the wrong size. Restate what Figma fixed. + if fixed_w && fixed_h && width.is_none() && height.is_none() { + width = view.number("width").map(px); + height = view.number("height").map(px); + } } else if is_page_root { // Figma page roots define the component canvas; their editor dimensions // are not emitted as runtime constraints. diff --git a/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs b/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs new file mode 100644 index 0000000..ed92211 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/folded_asset_size.rs @@ -0,0 +1,61 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::json; + +#[test] +fn fixed_non_square_frame_folded_into_mask_keeps_its_size() { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": ["1:root"], + "nodes": [ + { + "id": "1:root", "type": "FRAME", + "fields": { + "name": "Screen", "childrenIds": ["1:logo"], + "width": 100, "height": 100, + "fills": [{ + "type": "SOLID", "visible": true, + "color": {"r": 1, "g": 1, "b": 1} + }] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:logo", "type": "FRAME", + "fields": { + "name": "BI Logo", "parentId": "1:root", "childrenIds": ["1:vector"], + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "layoutPositioning": "ABSOLUTE", "width": 24, "height": 9, + "x": 64, "y": 79, + "targetAspectRatio": {"x": 79.9, "y": 29.9} + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:vector", "type": "VECTOR", + "fields": { + "name": "BI Logo Vector", "parentId": "1:logo", "childrenIds": [], + "fills": [{ + "type": "SOLID", "visible": true, + "color": {"r": 0, "g": 0, "b": 0} + }] + }, + "extra": {}, "fieldErrors": {} + } + ], + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + let tsx = generate_component(&snapshot, "1:root", &CodegenOptions::default()) + .expect("codegen") + .tsx; + + assert!(tsx.contains("maskImage=\"url('/icons/BI Logo.svg')\"")); + assert!( + tsx.contains("h=\"9px\"") && tsx.contains("w=\"24px\""), + "folded mask lost its fixed dimensions:\n{tsx}" + ); +} From 89995cb2593d98696c77017f31c97f66623c02d3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 22:02:28 +0900 Subject: [PATCH 33/69] fix(devup-ui): stop reporting a loss the absolute conversion no longer has absolute_layout_is_exact modelled the old behaviour, where an absolutely positioned node with children was left sizeless and therefore could not match Figma. Now that the layout pass restates pinned dimensions for exactly that case, the judgement was stale: it flagged the two nodes on the book cover screen whose conversion is in fact exact, while the nodes whose constraints really cannot be expressed in CSS are baked into a folded asset and never emitted at all. Both flagged nodes convert verbatim. The BI logo is pinned MAX/MAX and emits right=12px bottom=12px, matching 129-93-24 and 200-179-9; the checkbox is pinned MIN/MIN and emits left=6px top=6px. Reporting these as approximated demoted the whole screen to partial over a loss that does not exist. A node fixed on both axes is now treated as exactly sized even when it has children, in step with the branch that restates those dimensions. SCALE and STRETCH constraints remain unsupported and still mark a conversion approximated, and the existing assertion that the fallback diagnostic fires keeps that from being silently removed. Measured against the live file: all five screens now report status complete and projection exact with every fidelity axis full and no impacts. 268 upstream plugin goldens stay byte-identical. --- crates/devup-mcp-devup-ui/src/codegen/layout.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index ae5b6f5..8a521a0 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -302,6 +302,17 @@ pub(super) fn absolute_layout_is_exact(snapshot: &Snapshot, node: &RawNode) -> b }; let no_rotation = view.number("rotation").is_none_or(|value| value == 0.0); let exact_size = parent.is_some_and(|parent| { + // A node pinned on both axes now emits those exact dimensions even + // when it has children, because the absolute branch of + // `push_layout_props` restates them rather than letting the children + // define the box. Keep this in step with that branch: judging such a + // node approximated would report a loss the output no longer has. + if view.string("layoutSizingHorizontal") == Some("FIXED") + && view.string("layoutSizingVertical") == Some("FIXED") + && view.child_ids().next().is_some() + { + return true; + } if view.node_type() != "FRAME" { return false; } From d575259b1d52c4f8848cc45a915bc4a98d1accf0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 23:28:49 +0900 Subject: [PATCH 34/69] fix(figma): report an upstream refusal as itself, and hidden nodes as unexportable MCP delivers a refusal as a *successful* tool call whose result carries isError. Handing that to the collector made it hunt for data the response never contained and then blame the parser: "metadata not found in the Figma MCP response", or the same for snapshot data, variable batches, large-value fragments or asset descriptors, depending only on which step happened to receive it. Six sites could each mask the same cause. A Section target was only the first error delivered this way and had already been special-cased. Anything upstream refuses behaves identically, so the direct path now surfaces the message upstream sent, the way the handoff path always has. The reason arrived in the response every time; nothing was ever missing. This was found by making the parse failure report what it actually received. The observed shape now travels with the error, and it named the cause on the first occurrence: a Figma MCP tool-call rate limit. Every refresh costs 15 tool calls, which is why the failures looked intermittent, and why an earlier A/B misread them as a regression. Separately, the asset manifest advertised hidden nodes as available even though Figma refuses to export a node with no visible layers, so requesting one failed from inside Figma far from its cause. Such nodes are now reported failed with DEVUP_ASSET_NODE_HIDDEN and rejected up front with the reason. Restoring their visibility to force an export was considered and refused: an export path must not mutate the user's document, and an interrupted run would leave the node visible. Verified live: hidden assets are refused by name, a visible image fill still exports 9486 bytes, and 3997-48764 and 3997-45722 stay complete/exact with layout fully covered. 268 upstream plugin goldens byte-identical; 413 tests pass. --- crates/devup-mcp-figma/src/assets.rs | 26 ++++- crates/devup-mcp-figma/src/metadata.rs | 42 +++++++- crates/devup-mcp-figma/tests/assets.rs | 54 +++++++++- crates/devup-mcp/src/server/handoff.rs | 30 ++++++ crates/devup-mcp/src/server/mod.rs | 17 +++- .../tests/upstream_error_surfacing.rs | 99 +++++++++++++++++++ 6 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 crates/devup-mcp/tests/upstream_error_surfacing.rs diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 8a87539..26ad98f 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -316,6 +316,20 @@ fn manifest_entry(node: &RawNode, asset: AssetNode) -> AssetManifestEntry { ), }; + // Figma refuses to export a node that has no visible layers, so a hidden + // node can never produce bytes. Advertising it as available promised + // something the export would always refuse, and the caller only found out + // once the failure surfaced from inside Figma, far from its cause. + let hidden = node.typed_view().bool("visible") == Some(false); + let (status, error_code) = if hidden { + ( + AssetStatus::Failed, + Some("DEVUP_ASSET_NODE_HIDDEN".to_owned()), + ) + } else { + (AssetStatus::Available, None) + }; + AssetManifestEntry { asset_id, node_id: node.id.clone(), @@ -324,13 +338,13 @@ fn manifest_entry(node: &RawNode, asset: AssetNode) -> AssetManifestEntry { image_hash, format: None, scale: None, - status: AssetStatus::Available, + status, byte_length: None, sha256: None, mime_type: None, data_base64: None, output_path: None, - error_code: None, + error_code, } } @@ -362,6 +376,14 @@ pub fn validate_asset_requests( { return Err(invalid("asset request does not match the snapshot source.")); } + // Reject what the manifest already knows cannot be exported, so the + // reason travels with the rejection instead of arriving later as an + // opaque failure from inside Figma. + if candidate.status == AssetStatus::Failed { + return Err(invalid( + "The requested asset cannot be exported: the node is hidden in Figma.", + )); + } } Ok(()) } diff --git a/crates/devup-mcp-figma/src/metadata.rs b/crates/devup-mcp-figma/src/metadata.rs index b435727..3a21be6 100644 --- a/crates/devup-mcp-figma/src/metadata.rs +++ b/crates/devup-mcp-figma/src/metadata.rs @@ -1,6 +1,6 @@ use quick_xml::{Reader, XmlVersion, events::Event}; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{DevupError, ErrorCode, UpstreamResult}; @@ -54,14 +54,52 @@ pub fn metadata_from_result_for_target( }) .or_else(|| find_top_level_pages(&result.raw).map(MetadataResult::TopLevelPages)) .ok_or_else(|| { - DevupError::new( + DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, "metadata not found in the Figma MCP response.", false, + observed_response_shape(&result.raw), ) }) } +/// Summarises what actually arrived when metadata could not be parsed. +/// +/// This failure is intermittent, and reporting only that metadata was "not +/// found" gave no way to tell an empty response from a relayed error string or +/// an envelope shape the parser does not yet recognise — so every occurrence +/// had to be reproduced live to learn anything. Carrying the observed shape +/// with the error makes a single occurrence diagnosable. +fn observed_response_shape(value: &Value) -> Value { + fn previews(value: &Value, found: &mut Vec) { + if found.len() >= 4 { + return; + } + match value { + Value::Object(object) => object.values().for_each(|child| previews(child, found)), + Value::Array(values) => values.iter().for_each(|child| previews(child, found)), + Value::String(text) if !text.is_empty() => { + let mut preview: String = text.chars().take(200).collect(); + if text.chars().count() > 200 { + preview.push('…'); + } + found.push(preview); + } + _ => {} + } + } + + let mut texts = Vec::new(); + previews(value, &mut texts); + json!({ + "topLevelKeys": match value { + Value::Object(object) => object.keys().cloned().collect::>(), + _ => Vec::new(), + }, + "textPreviews": texts, + }) +} + fn find_top_level_pages(value: &Value) -> Option> { match value { Value::Object(object) => object.values().find_map(find_top_level_pages), diff --git a/crates/devup-mcp-figma/tests/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index ca7e079..189bd09 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -4,7 +4,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use devup_mcp_figma::{ AssetFormat, AssetRequest, AssetSelection, AssetStatus, CollectionRequest, CollectionScope, CollectorSession, CollectorStep, FigmaTarget, RawNode, ReadToolCall, Snapshot, UpstreamResult, - asset_export_from_result, discover_asset_manifest, + asset_export_from_result, discover_asset_manifest, validate_asset_requests, }; use serde_json::{Map, json}; use sha2::Digest as _; @@ -221,6 +221,58 @@ fn manifest_preserves_image_and_vector_source_details_without_exporting_bytes() assert!(manifest.assets[0].data_base64.is_none()); } +fn hidden_asset_snapshot() -> Snapshot { + Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["1:1".to_owned()], + nodes: [node( + "1:1", + "FRAME", + json!({ + "childrenIds": [], + "visible": false, + "isAsset": true, + "fills": [{"type": "IMAGE", "imageHash": "image-hash-123", "scaleMode": "FILL"}] + }), + )] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + } +} + +#[test] +fn hidden_node_is_reported_as_unexportable_instead_of_available() { + let manifest = discover_asset_manifest(&hidden_asset_snapshot()); + + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].status, AssetStatus::Failed); + assert_eq!( + manifest.assets[0].error_code.as_deref(), + Some("DEVUP_ASSET_NODE_HIDDEN") + ); +} + +#[test] +fn requesting_a_hidden_asset_is_rejected_with_the_reason() { + let error = validate_asset_requests( + &hidden_asset_snapshot(), + &[AssetRequest { + asset_id: "1:1:fills:0".to_owned(), + node_id: "1:1".to_owned(), + field: "fills/0".to_owned(), + image_hash: Some("image-hash-123".to_owned()), + format: AssetFormat::Png, + scale: 1, + }], + ) + .expect_err("a hidden node cannot be exported, so the request must be refused"); + + assert!(format!("{error:?}").contains("hidden"), "{error:?}"); +} + fn manifest_for(roots: &[&str], nodes: Vec) -> devup_mcp_figma::AssetManifest { discover_asset_manifest(&Snapshot { file_key: "FileKey123".to_owned(), diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index 06718f1..4c8205d 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -371,6 +371,36 @@ pub(crate) fn is_section_error_result(value: &Value) -> bool { && value.to_string().contains("DEVUP_TARGET_IS_SECTION") } +/// The message carried by an upstream result that reports a failure. +/// +/// A Section target was only the first error delivered this way. Anything +/// upstream refuses — a tool-call rate limit above all — arrives as a +/// *successful* MCP call carrying `isError`, and handing that to the +/// collector made it hunt for data the response never contained. It then +/// blamed the parser: "metadata not found in the Figma MCP response", or +/// the same for snapshot data, variable batches and asset descriptors, +/// depending only on which step happened to receive it. The real reason +/// was in the response the whole time, so return it and let the caller +/// read it. +pub(crate) fn upstream_error_message(value: &Value) -> Option { + if value.get("isError").and_then(Value::as_bool) != Some(true) { + return None; + } + fn first_text(value: &Value) -> Option { + match value { + Value::Object(object) => object + .get("text") + .and_then(Value::as_str) + .filter(|text| text.len() > 16) + .map(str::to_owned) + .or_else(|| object.values().find_map(first_text)), + Value::Array(values) => values.iter().find_map(first_text), + _ => None, + } + } + Some(first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned())) +} + fn take_session( state: &mut StoreState, session_id: &str, diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 856a1c9..cdb379f 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -313,7 +313,22 @@ impl DevupServer { return Err(error); } } - Ok(result) => collector.accept(&call_id, result)?, + // Every other upstream refusal arrives the same way. + // Report what upstream said instead of letting the + // collector misread the response as missing data. + Ok(result) => match handoff::upstream_error_message(&result.raw) { + Some(message) => { + let error = DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + message, + false, + ); + if !collector.reject(&call_id, &error)? { + return Err(error); + } + } + None => collector.accept(&call_id, result)?, + }, Err(error) if collector.reject(&call_id, &error)? => continue, Err(error) => return Err(error), } diff --git a/crates/devup-mcp/tests/upstream_error_surfacing.rs b/crates/devup-mcp/tests/upstream_error_surfacing.rs new file mode 100644 index 0000000..e1eb7b4 --- /dev/null +++ b/crates/devup-mcp/tests/upstream_error_surfacing.rs @@ -0,0 +1,99 @@ +//! An upstream refusal must be reported as itself. +//! +//! MCP delivers a refusal as a *successful* tool call whose result carries +//! `isError`. Handing that to the collector made it search the response for +//! data that was never in it and then blame the parser — "metadata not found +//! in the Figma MCP response", or the equivalent for snapshot data, variable +//! batches or asset descriptors, depending only on which step happened to +//! receive it. The reason was in the response all along. + +use std::sync::Arc; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{AuthStatus, DevupError, FigmaUpstream, ReadToolCall, UpstreamResult}; +use rmcp::{ServiceExt, model::CallToolRequestParams}; +use serde_json::{Map, Value, json}; + +/// Verbatim shape of a real Figma rate-limit response. +const RATE_LIMIT_TEXT: &str = "You've reached the Figma MCP tool call limit for your Full seat on the Professional plan. Upgrade your seat or plan for more tool calls."; + +#[derive(Debug)] +struct ConnectedAuth; + +#[async_trait] +impl DevupAuth for ConnectedAuth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +#[derive(Debug)] +struct RateLimitedUpstream; + +#[async_trait] +impl FigmaUpstream for RateLimitedUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Ok(UpstreamResult { + raw: json!({ + "content": [ + {"type": "text", "text": RATE_LIMIT_TEXT}, + {"type": "resource_link", "uri": "file://figma/docs/rate-limits-access.md"} + ], + "isError": true + }), + }) + } +} + +#[tokio::test] +async fn a_rate_limited_upstream_reports_its_own_reason_not_a_parse_failure() -> anyhow::Result<()> +{ + let server = DevupServer::new(Services::new( + Arc::new(ConnectedAuth), + Arc::new(RateLimitedUpstream), + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + + let error = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await + .expect_err("a refused collection must fail"); + let reported = error.to_string(); + + assert!( + reported.contains("tool call limit"), + "the upstream reason must survive: {reported}" + ); + assert!( + !reported.contains("not found in the Figma MCP response"), + "the refusal must not be reported as missing data: {reported}" + ); + + client.cancel().await?; + task.abort(); + Ok(()) +} From 19921fbc0f7ff0d96e4def903493922f719629ca Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 23:36:52 +0900 Subject: [PATCH 35/69] fix(server): let the schema and the errors state what is actually accepted The outputs and format fields were plain strings, so the published schema advertised no values and a caller could only discover the set one rejection at a time. outputs now takes its enum from the same constant the validator checks, which is what keeps the schema from drifting away from what is accepted, and the rejection names the supported set instead of only the rejected value. An asset requested as jpg or pdf failed with "asset export response does not contain the requested binary", which described the symptom and left the caller no idea what to do. Figma does export those bytes and does write the file, but upstream only returns a written file as an attachment for png, and svg is carried back inline for exactly that reason. Since the limit is in the transport rather than in the export, the error now says so and names the formats that do round-trip. Measured against the live file: the schema advertises both sets, a jpg request explains itself, and svg still exports 6291 bytes. A png request in the same run hit the Figma tool-call rate limit and reported it verbatim with its help link rather than as a parse failure, which is the previous commit working in the wild. --- crates/devup-mcp-figma/src/assets.rs | 7 ++++++- crates/devup-mcp/src/server/tools.rs | 5 +++++ crates/devup-mcp/src/server/validation.rs | 24 ++++++++++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 26ad98f..0af1f30 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -478,7 +478,12 @@ pub fn asset_export_from_result( // search does not read" — three very different bugs. DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, - "asset export response does not contain the requested binary.", + format!( + "Figma exported the asset but did not return the {} bytes. \ + Upstream returns written files as an attachment only for png; \ + svg is carried inline. Request png or svg instead.", + request.format.extension() + ), false, json!({ "expectedMimeType": request.format.mime_type(), diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index b3659c0..7da0be5 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -65,6 +65,10 @@ pub struct FigmaExportInput { #[serde(default)] pub artifact_id: Option, #[serde(default = "default_outputs")] + #[schemars(extend("items" = serde_json::json!({ + "type": "string", + "enum": super::validation::EXPORT_OUTPUTS, + })))] pub outputs: Vec, #[serde(default)] pub component_name: Option, @@ -97,6 +101,7 @@ pub struct FigmaExportInput { pub struct FigmaAssetRequestInput { pub asset_id: String, #[serde(default = "default_asset_format")] + #[schemars(extend("enum" = ["png", "jpg", "svg", "pdf"]))] pub format: String, #[serde(default = "default_asset_scale")] pub scale: u8, diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index fc2102c..dda74da 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -10,6 +10,20 @@ use super::{ tools::FigmaAssetRequestInput, }; +/// The export outputs this server understands. +/// +/// The JSON schema for `outputs` advertises this same constant, so a caller +/// can discover the set instead of learning it one rejection at a time, and +/// the published schema cannot drift from what is actually accepted. +pub(crate) const EXPORT_OUTPUTS: [&str; 6] = [ + "tsx", + "devupJson", + "rawSnapshot", + "sourceMap", + "assetManifest", + "referencePng", +]; + pub(super) fn validate_artifact_projection( artifact: &ArtifactLookup, outputs: &[String], @@ -85,13 +99,13 @@ pub(super) fn validate_outputs(outputs: &[String]) -> Result<(), DevupError> { )); } for output in outputs { - if !matches!( - output.as_str(), - "tsx" | "devupJson" | "rawSnapshot" | "sourceMap" | "assetManifest" | "referencePng" - ) { + if !EXPORT_OUTPUTS.contains(&output.as_str()) { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("Unsupported export output: {output}"), + format!( + "Unsupported export output: {output}. Supported: {}.", + EXPORT_OUTPUTS.join(", ") + ), false, )); } From 9674eafc0998ed0ce6292ff9133b3282514c81d8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 23:44:36 +0900 Subject: [PATCH 36/69] fix(figma): classify a quota refusal as retryable instead of permanent Surfacing the upstream message was only half the answer. A tool-call quota refusal was still reported as DEVUP_SNAPSHOT_UNSUPPORTED and retryable: false, which tells the caller to give up on the one upstream failure that clears on its own. It also bypassed the rate-limit handling the codebase already had: ErrorCode::DevupFigmaRateLimited exists and maps to UpstreamFailureKind::RateLimited, which the source policy already understands. A quota refusal now carries that code, is marked retryable, and explains when it clears. Figma meters reads per minute alongside a daily or monthly allowance, so the per-minute ceiling is what a caller normally hits, and it frees itself within a minute. The details also state what a call costs: a refreshed export spends roughly fifteen Figma tool calls, which is why repeated refreshes exhaust an allowance so quickly. Verified live while actually rate limited: code DEVUP_FIGMA_RATE_LIMITED, retryable true, with Figma's own message and help link intact. The regression test pins the classification and fails without it. --- crates/devup-mcp/src/server/handoff.rs | 27 +++++++++++++++++-- crates/devup-mcp/src/server/mod.rs | 9 ++----- .../tests/upstream_error_surfacing.rs | 10 +++++++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index 4c8205d..c3eccfc 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -382,7 +382,7 @@ pub(crate) fn is_section_error_result(value: &Value) -> bool { /// depending only on which step happened to receive it. The real reason /// was in the response the whole time, so return it and let the caller /// read it. -pub(crate) fn upstream_error_message(value: &Value) -> Option { +pub(crate) fn upstream_error(value: &Value) -> Option { if value.get("isError").and_then(Value::as_bool) != Some(true) { return None; } @@ -398,7 +398,30 @@ pub(crate) fn upstream_error_message(value: &Value) -> Option { _ => None, } } - Some(first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned())) + let message = first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned()); + + // A quota refusal is the one upstream failure that clears on its own, + // so it must not be reported as a permanent one. Figma meters reads + // per minute alongside a daily or monthly allowance, and a single + // refreshed export spends roughly fifteen calls, so the per-minute + // ceiling is reached long before the longer-term one. + let lowered = message.to_lowercase(); + if lowered.contains("tool call limit") || lowered.contains("rate limit") { + return Some(DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + message, + true, + json!({ + "resets": "Per-minute limits clear within a minute; the daily or monthly allowance resets on its own schedule.", + "costHint": "A refreshed export spends about 15 Figma tool calls, so avoid refresh when a cached artifact will do.", + }), + )); + } + Some(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + message, + false, + )) } fn take_session( diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index cdb379f..f30d21d 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -316,13 +316,8 @@ impl DevupServer { // Every other upstream refusal arrives the same way. // Report what upstream said instead of letting the // collector misread the response as missing data. - Ok(result) => match handoff::upstream_error_message(&result.raw) { - Some(message) => { - let error = DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - message, - false, - ); + Ok(result) => match handoff::upstream_error(&result.raw) { + Some(error) => { if !collector.reject(&call_id, &error)? { return Err(error); } diff --git a/crates/devup-mcp/tests/upstream_error_surfacing.rs b/crates/devup-mcp/tests/upstream_error_surfacing.rs index e1eb7b4..2491e50 100644 --- a/crates/devup-mcp/tests/upstream_error_surfacing.rs +++ b/crates/devup-mcp/tests/upstream_error_surfacing.rs @@ -92,6 +92,16 @@ async fn a_rate_limited_upstream_reports_its_own_reason_not_a_parse_failure() -> !reported.contains("not found in the Figma MCP response"), "the refusal must not be reported as missing data: {reported}" ); + // A quota refusal clears on its own, so reporting it as permanent would + // tell the caller to give up on something that fixes itself. + assert!( + reported.contains("DEVUP_FIGMA_RATE_LIMITED"), + "a quota refusal must be classified as one: {reported}" + ); + assert!( + reported.contains("\"retryable\":true"), + "a quota refusal must be retryable: {reported}" + ); client.cancel().await?; task.abort(); From a2a2481522d008832c626f0231da209d249d3533 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 3 Sep 2026 23:56:33 +0900 Subject: [PATCH 37/69] fix(figma): describe quota recovery as it works, not as a reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous message said a daily or monthly allowance "resets on its own schedule", which was written without checking. Figma meters reads with a leaky bucket, so capacity drains back continuously and there is no rollover to wait for. Telling a caller to wait for one would have them wait for something that never arrives, and it also explains a pattern that looked like flakiness all along: with a nearly full bucket a one-call request slips through while a fifteen-call refresh is still refused. Which ceiling was reached is not something this server can honestly claim. Figma's REST API states the exact wait in Retry-After and names the ceiling in X-Figma-Rate-Limit-Type, but the MCP relay forwards neither today. Retry-After is therefore read wherever it appears and reported as retryAfterSeconds, and when it is absent the response says the ceiling was not stated rather than picking one on the caller's behalf. Verified live while rate limited: DEVUP_FIGMA_RATE_LIMITED, retryable, with the gradual-recovery description and an explicit "not stated". Two tests pin both paths — the stated wait being surfaced, and an unstated one being admitted. --- crates/devup-mcp/src/server/handoff.rs | 55 ++++++++++++-- .../tests/upstream_error_surfacing.rs | 76 +++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs index c3eccfc..c73876a 100644 --- a/crates/devup-mcp/src/server/handoff.rs +++ b/crates/devup-mcp/src/server/handoff.rs @@ -382,6 +382,28 @@ pub(crate) fn is_section_error_result(value: &Value) -> bool { /// depending only on which step happened to receive it. The real reason /// was in the response the whole time, so return it and let the caller /// read it. +/// The wait Figma asked for, in seconds, wherever it appears. +/// +/// Figma's REST API answers a 429 with `Retry-After`. The MCP relay does +/// not forward response headers today, so this usually finds nothing — but +/// reading it costs nothing and is the only authoritative answer to "when +/// can I retry", which otherwise has to be guessed. +fn retry_after_seconds(value: &Value) -> Option { + match value { + Value::Object(object) => object + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("retry-after") || *key == "retryAfter") + .and_then(|(_, found)| { + found + .as_u64() + .or_else(|| found.as_str().and_then(|text| text.parse().ok())) + }) + .or_else(|| object.values().find_map(retry_after_seconds)), + Value::Array(values) => values.iter().find_map(retry_after_seconds), + _ => None, + } +} + pub(crate) fn upstream_error(value: &Value) -> Option { if value.get("isError").and_then(Value::as_bool) != Some(true) { return None; @@ -401,20 +423,37 @@ pub(crate) fn upstream_error(value: &Value) -> Option { let message = first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned()); // A quota refusal is the one upstream failure that clears on its own, - // so it must not be reported as a permanent one. Figma meters reads - // per minute alongside a daily or monthly allowance, and a single - // refreshed export spends roughly fifteen calls, so the per-minute - // ceiling is reached long before the longer-term one. + // so it must not be reported as a permanent one. let lowered = message.to_lowercase(); if lowered.contains("tool call limit") || lowered.contains("rate limit") { + let mut details = json!({ + // Figma meters reads with a leaky bucket, so there is no reset + // hour to wait for: capacity drains back continuously. Saying + // an allowance "resets tomorrow" would invite waiting for a + // rollover that never happens, and it explains why small + // requests slip through while a large one still fails. + "recovery": "Figma meters reads with a leaky bucket, so capacity returns gradually rather than resetting at a fixed time. Retry after a short wait; a small request may succeed while a large one is still refused.", + "costHint": "A refreshed export spends about 15 Figma tool calls, so prefer a cached artifact over refresh.", + }); + // The REST API states the exact wait in `Retry-After`, and names + // the ceiling in `X-Figma-Rate-Limit-Type`. The MCP relay does not + // forward either today, so read them when present rather than + // guessing, and say plainly when they are absent. + match retry_after_seconds(value) { + Some(seconds) => { + details["retryAfterSeconds"] = json!(seconds); + } + None => { + details["whichLimit"] = json!( + "Not stated. Figma applies a per-minute ceiling alongside a daily or monthly allowance, and the MCP response does not say which was reached." + ); + } + } return Some(DevupError::with_details( ErrorCode::DevupFigmaRateLimited, message, true, - json!({ - "resets": "Per-minute limits clear within a minute; the daily or monthly allowance resets on its own schedule.", - "costHint": "A refreshed export spends about 15 Figma tool calls, so avoid refresh when a cached artifact will do.", - }), + details, )); } Some(DevupError::new( diff --git a/crates/devup-mcp/tests/upstream_error_surfacing.rs b/crates/devup-mcp/tests/upstream_error_surfacing.rs index 2491e50..7dd2d67 100644 --- a/crates/devup-mcp/tests/upstream_error_surfacing.rs +++ b/crates/devup-mcp/tests/upstream_error_surfacing.rs @@ -55,6 +55,70 @@ impl FigmaUpstream for RateLimitedUpstream { } } +/// Same refusal, but with the wait Figma's REST API states in `Retry-After`. +/// The MCP relay does not forward it today; this pins that it is used the +/// moment it appears, rather than the caller being told to guess. +#[derive(Debug)] +struct RateLimitedWithRetryAfter; + +#[async_trait] +impl FigmaUpstream for RateLimitedWithRetryAfter { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Ok(UpstreamResult { + raw: json!({ + "content": [{"type": "text", "text": RATE_LIMIT_TEXT}], + "isError": true, + "headers": {"Retry-After": 42} + }), + }) + } +} + +#[tokio::test] +async fn a_stated_retry_after_is_reported_instead_of_a_guess() -> anyhow::Result<()> { + let server = DevupServer::new(Services::new( + Arc::new(ConnectedAuth), + Arc::new(RateLimitedWithRetryAfter), + )); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=10-1", + "outputs": ["tsx"], + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .expect("arguments object"); + + let reported = client + .call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(arguments)) + .await + .expect_err("a refused collection must fail") + .to_string(); + + assert!( + reported.contains("\"retryAfterSeconds\":42"), + "the stated wait must be surfaced: {reported}" + ); + assert!( + !reported.contains("Not stated"), + "a stated wait must not also be reported as unstated: {reported}" + ); + + client.cancel().await?; + task.abort(); + Ok(()) +} + #[tokio::test] async fn a_rate_limited_upstream_reports_its_own_reason_not_a_parse_failure() -> anyhow::Result<()> { @@ -102,6 +166,18 @@ async fn a_rate_limited_upstream_reports_its_own_reason_not_a_parse_failure() -> reported.contains("\"retryable\":true"), "a quota refusal must be retryable: {reported}" ); + // Figma meters with a leaky bucket, so promising a reset would send the + // caller waiting for a rollover that never arrives. + assert!( + reported.contains("leaky bucket"), + "recovery must be described as gradual: {reported}" + ); + // This relay forwards no Retry-After, so the response must admit that + // rather than pick a ceiling on the caller's behalf. + assert!( + reported.contains("Not stated"), + "an unstated ceiling must be reported as unstated: {reported}" + ); client.cancel().await?; task.abort(); From d1d29932a3b45ffb5f5d90bb47ee8f108b44de9b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 01:52:21 +0900 Subject: [PATCH 38/69] fix(devup-ui): place children of a frame that has no auto-layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma keeps padding fields on a frame long after its auto-layout is gone, and those values no longer place anything — the children sit wherever their own x/y puts them. Trusting the stale fields put everything at the wrong offset: on the book cover screen the book rendered against the top-left corner of its green panel at a flat 10px inset, where the design centres it. Figma normally reports the real gap as the padding of the auto-layout it infers for such a frame, and that path already worked. It declines to infer one for this panel, so the same quantity is now measured from the children's bounding box instead. Both routes describe the same thing, which is why the upstream fixtures that rely on the inferred values are untouched: the panel now emits pl=116px pr=115px py=20px, matching the plugin exactly, and 116+129 +115 and 20+200+20 come back to the panel's own 360x240. Zero paddings are no longer named. They are the default, so a `px="0px"` earned its place only by sitting next to a real `py`, and deriving insets turned up more of them. The plugin omits them too. Seven committed snapshots move with this. Every one is a modal overlay whose dialog was pinned to a corner and is now inset the distance the design gives it, or a zero padding that stopped being spelled out. Verified live: the book panel matches the plugin, the header no longer carries px="0px", the screen stays complete with layout fully covered, and all 268 upstream goldens remain byte-identical. --- .../devup-mcp-devup-ui/src/codegen/layout.rs | 78 ++++++++++++++++--- ...151_frames__wquw_151_frame_3879_35503.snap | 9 +-- ...151_frames__wquw_151_frame_3879_35569.snap | 5 +- ...151_frames__wquw_151_frame_3879_35652.snap | 5 +- ...151_frames__wquw_151_frame_3879_35729.snap | 5 +- ...151_frames__wquw_151_frame_3879_35887.snap | 7 +- ...151_frames__wquw_151_frame_3879_35973.snap | 8 +- ...151_frames__wquw_151_frame_3879_36059.snap | 6 +- ...151_frames__wquw_151_frame_3879_36108.snap | 4 +- 9 files changed, 97 insertions(+), 30 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 8a521a0..702f900 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -251,7 +251,7 @@ pub(super) fn push_layout_props( } push_auto_layout(node, component, props); - push_padding(node, props); + push_padding(snapshot, node, props); if view.bool("clipsContent") == Some(true) { string_prop(props, "overflow", "hidden"); } @@ -434,13 +434,65 @@ fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { } } -fn push_padding(node: &RawNode, props: &mut Vec) { +/// The gap between a frame's edges and the box its children occupy. +/// +/// Figma reports this as the padding of the auto-layout it infers for a frame +/// that has none. When it declines to infer one the same quantity still +/// describes the frame, so measure it rather than fall back to the frame's own +/// padding fields, which linger from whenever it last had a layout and no +/// longer place anything. +fn children_inset(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { + let view = node.typed_view(); + let (width, height) = (view.number("width")?, view.number("height")?); + let mut bounds: Option<[f64; 4]> = None; + for child in view.child_ids().filter_map(|id| snapshot.nodes.get(id)) { + let child = child.typed_view(); + if child.bool("visible") == Some(false) { + continue; + } + let (Some(x), Some(y), Some(child_width), Some(child_height)) = ( + child.number("x"), + child.number("y"), + child.number("width"), + child.number("height"), + ) else { + continue; + }; + bounds = Some(match bounds { + Some([left, top, right, bottom]) => [ + left.min(x), + top.min(y), + right.max(x + child_width), + bottom.max(y + child_height), + ], + None => [x, y, x + child_width, y + child_height], + }); + } + let [left, top, right, bottom] = bounds?; + let inset = [top, width - right, height - bottom, left]; + // Children can sit outside the frame, and a negative padding describes + // nothing. + inset.iter().all(|edge| *edge >= 0.0).then_some(inset) +} + +fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { let view = node.typed_view(); let inferred = view.value("inferredAutoLayout").and_then(Value::as_object); + let derived = (inferred.is_none() && view.string("layoutMode") == Some("NONE")) + .then(|| children_inset(snapshot, node)) + .flatten(); let get = |name: &str| { inferred .and_then(|value| value.get(name)) .and_then(Value::as_f64) + .or_else(|| { + derived.map(|[top, right, bottom, left]| match name { + "paddingTop" => top, + "paddingRight" => right, + "paddingBottom" => bottom, + _ => left, + }) + }) .or_else(|| view.number(name)) }; let [Some(top), Some(right), Some(bottom), Some(left)] = [ @@ -454,20 +506,28 @@ fn push_padding(node: &RawNode, props: &mut Vec) { if top == 0.0 && right == 0.0 && bottom == 0.0 && left == 0.0 { return; } + // A zero padding is the default, so naming it says nothing. Emitting it + // only because the other axis happened to be padded left props like + // `px="0px"` sitting next to a real `py`. + let mut push = |name: &str, value: f64| { + if value != 0.0 { + string_prop(props, name, px(value)); + } + }; if top == right && right == bottom && bottom == left { - string_prop(props, "p", px(top)); + push("p", top); } else { if top == bottom { - string_prop(props, "py", px(top)); + push("py", top); } else { - string_prop(props, "pt", px(top)); - string_prop(props, "pb", px(bottom)); + push("pt", top); + push("pb", bottom); } if left == right { - string_prop(props, "px", px(left)); + push("px", left); } else { - string_prop(props, "pl", px(left)); - string_prop(props, "pr", px(right)); + push("pl", left); + push("pr", right); } } } diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap index 44892ac..3e61869 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35503.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Image, Text, VStack } from "@devup-ui/react"; @@ -14,13 +15,7 @@ export function Wquw151Frame387935503() { overflow="hidden" w="360px" > -
+
- + diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap index f9eb53d..cc059a4 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Flex, Image, Text, VStack } from "@devup-ui/react"; @@ -394,6 +395,7 @@ export function Wquw151Frame387935652() { left="50%" overflow="hidden" pos="absolute" + pt="371px" top="0px" transform="translateX(-50%)" w="100%" @@ -404,11 +406,10 @@ export function Wquw151Frame387935652() { boxShadow="0 -8px 20px 0 #00000026" overflow="hidden" pb="12px" - pt="0px" px="12px" w="360px" > - + diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap index bde8a81..c422a64 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Flex, Image, Text, VStack } from "@devup-ui/react"; @@ -394,6 +395,7 @@ export function Wquw151Frame387935729() { left="50%" overflow="hidden" pos="absolute" + pt="454px" top="0px" transform="translateX(-50%)" w="100%" @@ -404,11 +406,10 @@ export function Wquw151Frame387935729() { boxShadow="0 -8px 20px 0 #00000026" overflow="hidden" pb="12px" - pt="0px" px="12px" w="360px" > - + diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap index a88be3f..2b33820 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Flex, Image, Text, VStack } from "@devup-ui/react"; @@ -394,6 +395,7 @@ export function Wquw151Frame387935887() { left="50%" overflow="hidden" pos="absolute" + pt="171px" top="0px" transform="translateX(-50%)" w="100%" @@ -404,11 +406,10 @@ export function Wquw151Frame387935887() { boxShadow="0 -8px 20px 0 #00000026" overflow="hidden" pb="12px" - pt="0px" px="12px" w="360px" > - + @@ -574,6 +575,8 @@ export function Wquw151Frame387935887() { left="50%" overflow="hidden" pos="absolute" + px="20px" + py="233.5px" top="0px" transform="translateX(-50%)" w="100%" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap index fea50e7..1ac4962 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Flex, Image, Text, VStack } from "@devup-ui/react"; @@ -394,6 +395,7 @@ export function Wquw151Frame387935973() { left="50%" overflow="hidden" pos="absolute" + pt="171px" top="0px" transform="translateX(-50%)" w="100%" @@ -404,11 +406,10 @@ export function Wquw151Frame387935973() { boxShadow="0 -8px 20px 0 #00000026" overflow="hidden" pb="12px" - pt="0px" px="12px" w="360px" > - + @@ -573,7 +574,10 @@ export function Wquw151Frame387935973() { bg="#000000B2" left="50%" overflow="hidden" + pb="218.5px" pos="absolute" + pt="219.5px" + px="20px" top="0px" transform="translateX(-50%)" w="100%" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap index db477f9..93ac2b0 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap @@ -1,5 +1,6 @@ --- source: crates/devup-mcp-devup-ui/tests/wquw_151_frames.rs +assertion_line: 218 expression: output.tsx --- import { Box, Center, Flex, Image, Text, VStack } from "@devup-ui/react"; @@ -18,7 +19,6 @@ export function Wquw151Frame387936059() { h="66px" justifyContent="space-between" overflow="hidden" - px="0px" py="8px" w="360px" > @@ -32,7 +32,7 @@ export function Wquw151Frame387936059() { maskSize="contain" w="50px" /> -
+
@@ -26,7 +26,7 @@ export function Wquw151Frame387936108() { maskSize="contain" w="50px" /> -
+
Date: Fri, 4 Sep 2026 02:05:33 +0900 Subject: [PATCH 39/69] fix(devup-ui): only space apart children that are actually rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gap describes the distance between rendered siblings. The check counted every child, so a row holding one visible label beside a `display: none` one was given `gap="6px"` — a separation between a thing and nothing. Three rows on the book cover screen carried it; the plugin emits none of them. The fidelity report repeated the same count, so it is corrected alongside: having deliberately not expressed a meaningless spacing, reporting it as an unrepresented layout fact would describe the omission as a loss. The screen goes back to layout fully covered, at 122/122 rather than 128, because six facts stopped being facts about the output. All 268 upstream plugin goldens remain byte-identical. --- crates/devup-mcp-devup-ui/src/codegen/layout.rs | 16 ++++++++++++---- crates/devup-mcp-devup-ui/src/provenance.rs | 12 +++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 702f900..ced70b4 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -250,7 +250,7 @@ pub(super) fn push_layout_props( string_prop(props, "flex", "1"); } - push_auto_layout(node, component, props); + push_auto_layout(snapshot, node, component, props); push_padding(snapshot, node, props); if view.bool("clipsContent") == Some(true) { string_prop(props, "overflow", "hidden"); @@ -359,7 +359,7 @@ fn child_shrinker(parent: &RawNode, dimension: &str) -> bool { } } -fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { +fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: &mut Vec) { let view = node.typed_view(); let Some(layout) = view.value("inferredAutoLayout").and_then(Value::as_object) else { return; @@ -422,8 +422,16 @@ fn push_auto_layout(node: &RawNode, component: &str, props: &mut Vec) { if component == "Center" && mode == Some("VERTICAL") { string_prop(props, "flexDir", "column"); } - if view.child_ids().count() > 1 && view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") - { + // Spacing only means something between things that are actually there. A + // hidden child is not rendered, so a frame holding one visible child and + // one `display: none` sibling has nothing to space apart, and naming a gap + // implies a separation the design does not have. + let visible_children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + .count(); + if visible_children > 1 && view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") { let gap = layout .get("itemSpacing") .and_then(Value::as_f64) diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 94357a1..aff4275 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -520,7 +520,17 @@ fn layout_field_is_semantic( .is_none_or(|value| value == "FIXED") } "itemSpacing" => { - view.child_ids().count() > 1 + // Spacing describes the distance between rendered siblings, so a + // hidden child leaves nothing to space apart and the generated code + // rightly omits the gap. Counting it here would report a shortfall + // for a fact that was deliberately not expressed. Kept in step with + // the same test in `codegen::layout`. + let visible_children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + .count(); + visible_children > 1 && view.string("primaryAxisAlignItems") != Some("SPACE_BETWEEN") && !projects_as_asset(snapshot, node) && view.number(field).is_some_and(|value| value != 0.0) From 8750864e7f2258d4047bfec611adfecda4471788 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 02:22:40 +0900 Subject: [PATCH 40/69] fix(figma): collect the text truncation setting instead of assuming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated text carried overflow="hidden" and textOverflow="ellipsis", claiming a truncation the designs never asked for. The check reads Figma's own textTruncation and only skips when it says DISABLED, which is right — but the field was missing from the collected field manifest, so it always read nothing and nothing is not DISABLED. The upstream fixtures show the rule itself is sound: wherever they carry the field the plugin follows it exactly, DISABLED emitting no ellipsis and ENDING emitting one. Only the three synthetic cases that omit the field entirely rely on the absent-means-on reading, and a real export never omits it. Collecting the field is therefore the whole fix, and all 268 goldens stay byte-identical. maxLines is collected alongside it, for the line clamp that reads it and had the same gap. --- crates/devup-mcp-devup-ui/src/codegen/text.rs | 4 ++++ crates/devup-mcp-figma/src/plugin_api_manifest.json | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/text.rs b/crates/devup-mcp-devup-ui/src/codegen/text.rs index c4d7e51..1d1bdc0 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text.rs @@ -94,6 +94,10 @@ pub(super) fn push_text_props( string_prop(props, "display", "-webkit-box"); } } + // Reads the designer's own truncation setting, which Figma always + // reports — provided it is collected. It was missing from the field + // manifest, so this saw nothing and every text claimed an ellipsis the + // design never asked for. if view.string("textTruncation") != Some("DISABLED") && view.string("layoutSizingHorizontal") != Some("HUG") { diff --git a/crates/devup-mcp-figma/src/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index b1c8d8b..5bcd048 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -7,12 +7,13 @@ "gridColumnGap", "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridStyleId", "height", "inferredAutoLayout", "isAsset", "isMask", "itemSpacing", "layoutGrow", "layoutMode", "layoutPositioning", "layoutSizingHorizontal", "layoutSizingVertical", - "letterSpacing", "lineHeight", "maxHeight", "maxWidth", "minHeight", + "letterSpacing", "lineHeight", "maxHeight", "maxLines", "maxWidth", "minHeight", "minWidth", "name", "opacity", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "primaryAxisAlignItems", "reactions", "rotation", "strokeAlign", "strokeBottomWeight", "strokeLeftWeight", "strokeRightWeight", "strokes", "strokeStyleId", "strokeTopWeight", "strokeWeight", "targetAspectRatio", "textAlignHorizontal", "textAlignVertical", "textAutoResize", "textCase", "textDecoration", "textStyleId", + "textTruncation", "topLeftRadius", "topRightRadius", "variantProperties", "visible", "width", "x", "y" ] From 180aa89149ebeb6e36d562b1ef223f4104699518 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 02:34:59 +0900 Subject: [PATCH 41/69] fix(devup-ui): stop turning a screen's canvas width into a constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame being exported carries the width the designer drew at. Restating it pins the generated screen to a device size that does not exist, and the code already knew this: a frame whose parent is a page, section or component set is treated as a canvas and keeps its dimensions to itself. That test could never pass for the thing actually being exported. A root's parent lies outside the collected subtree, so looking it up found nothing and the frame read as having no parent at all. The nodes now carry their parent's type, and the decision falls back to it. Only a root records it. Every other node's parent is collected and can be read directly, and adding it everywhere grew the payload by six kilobytes — enough, on this screen, to push the response over the threshold into chunked delivery. A child that happens to span the full width still states it: 360px on the header is a measurement, on the screen it is the canvas. Both are pinned by tests, along with the unattributed case the upstream fixtures rely on, and all 268 goldens stay byte-identical. --- .../devup-mcp-devup-ui/src/codegen/layout.rs | 15 ++- .../tests/root_canvas_width.rs | 106 ++++++++++++++++++ .../src/scripts/fast_snapshot.js | 9 ++ .../devup-mcp-figma/src/scripts/snapshot.js | 6 + 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/root_canvas_width.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index ced70b4..95f7c10 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -19,12 +19,15 @@ pub(super) fn push_layout_props( .any(|child| child == node.id) }); let is_root = snapshot.roots.iter().any(|root| root == &node.id); - let is_page_root = parent.is_some_and(|parent| { - matches!( - parent.typed_view().node_type(), - "SECTION" | "PAGE" | "COMPONENT_SET" - ) - }); + // The parent of a collected root sits outside the collected subtree, so it + // cannot be looked up and the node's recorded parent type is the only + // account of it. Without that fallback a screen read as having no parent at + // all and its canvas width was emitted as a real constraint, pinning the + // result to a device size that does not exist. + let is_page_root = parent + .map(|parent| parent.typed_view().node_type()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); let fixed_w = view.string("layoutSizingHorizontal") == Some("FIXED"); let fixed_h = view.string("layoutSizingVertical") == Some("FIXED"); let fill_w = view.string("layoutSizingHorizontal") == Some("FILL"); diff --git a/crates/devup-mcp-devup-ui/tests/root_canvas_width.rs b/crates/devup-mcp-devup-ui/tests/root_canvas_width.rs new file mode 100644 index 0000000..784d459 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/root_canvas_width.rs @@ -0,0 +1,106 @@ +//! A screen's own width is the canvas it was drawn on, not a constraint. +//! +//! The frame being exported sits on a page or section, so its width is simply +//! the size the designer worked at. Emitting it pins the result to a device +//! width that does not exist. The parent that establishes this is outside the +//! collected subtree, so the node carries its parent's type and that is what +//! the decision reads. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +fn screen(parent_type: Option<&str>) -> String { + let mut root = json!({ + "name": "Screen", + "childrenIds": ["1:header"], + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "HUG", + "width": 360.0, + "height": 1238.0, + "parentId": "0:page" + }); + if let Some(parent_type) = parent_type { + root["parentType"] = json!(parent_type); + } + + generate( + "1:screen", + json!([ + {"id": "1:screen", "type": "FRAME", "fields": root, "extra": {}, "fieldErrors": {}}, + { + "id": "1:header", "type": "FRAME", + "fields": { + "name": "Header", "parentId": "1:screen", "childrenIds": [], + "layoutMode": "HORIZONTAL", + "layoutSizingHorizontal": "FIXED", + "layoutSizingVertical": "FIXED", + "width": 360.0, "height": 66.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ) +} + +/// The root's opening tag. Props are formatted across several lines, so +/// matching a single line would find ` String { + let start = tsx + .find("return (") + .and_then(|from| tsx[from..].find('<').map(|at| from + at)) + .unwrap_or_else(|| panic!("a root element in:\n{tsx}")); + let end = tsx[start..].find('>').expect("a closed tag") + start; + tsx[start..=end].to_owned() +} + +#[test] +fn a_screen_on_a_section_does_not_restate_its_canvas_width() { + let tag = root_tag(&screen(Some("SECTION"))); + + assert!( + !tag.contains("w=\"360px\""), + "the canvas width must not become a constraint: {tag}" + ); +} + +#[test] +fn a_child_that_happens_to_be_full_width_still_states_it() { + let tsx = screen(Some("SECTION")); + + // The header is 360 wide too, but it is a child rather than the canvas, so + // its width is a real measurement and has to survive. + assert!( + tsx[root_tag(&tsx).len()..].contains("w=\"360px\""), + "a child's own width is not canvas geometry: {tsx}" + ); +} + +#[test] +fn without_a_recorded_parent_type_the_width_is_still_emitted() { + // Nothing says this frame is a screen, so the width is all there is to go + // on. This is what the upstream fixtures exercise, and it must not change. + let tag = root_tag(&screen(None)); + + assert!( + tag.contains("w=\"360px\""), + "an unattributed frame keeps its width: {tag}" + ); +} diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 5d97add..0d6832f 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -156,6 +156,15 @@ function snapshotNode(node) { const fields = {}; const fieldErrors = {}; if (node.parent) fields.parentId = node.parent.id; + // Only a root needs this. Its parent lies outside the collected subtree, so + // the id alone says nothing, and the parent's type is what decides whether + // the root's width is a real constraint or merely the canvas the design was + // drawn on. Every other node's parent is collected and can be read directly, + // so recording it there would be repetition — and repeated across a whole + // screen it was enough to push the payload into chunked delivery. + if (node.parent && requestedRootIds.includes(node.id)) { + fields.parentType = node.parent.type; + } const childrenIds = "children" in node ? node.children.map((child) => child.id) : []; if (childrenIds.length > 0) fields.childrenIds = childrenIds; diff --git a/crates/devup-mcp-figma/src/scripts/snapshot.js b/crates/devup-mcp-figma/src/scripts/snapshot.js index 9278f1a..922aeab 100644 --- a/crates/devup-mcp-figma/src/scripts/snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/snapshot.js @@ -160,6 +160,12 @@ function snapshotNode(node) { const extra = {}; const fieldErrors = {}; fields.parentId = node.parent ? node.parent.id : null; + // Only the root needs this. Its parent lies outside the collected subtree, + // so the id alone says nothing, and the parent's type is what decides + // whether the root's width is a real constraint or merely the canvas the + // design was drawn on. Every other node's parent is collected and can be + // read directly. + if (node.parent && node.id === root.id) fields.parentType = node.parent.type; fields.childrenIds = "children" in node ? node.children.map((child) => child.id) : []; for (const name of propertyNames(node)) { From 1be20d0705a318a339aa814838c68e705ccf8de8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 02:40:15 +0900 Subject: [PATCH 42/69] feat(devup-ui): give each image fill its own file instead of one shared name Every image fill resolved to a single hard-coded /icons/image.png. That lost three separate things at once: a raster was pointed at the icon folder, unrelated images from different nodes all claimed the same file and overwrote one another on disk, and two fills on one node produced the identical URL twice, so a layered background repeated one picture. On the book cover screen three distinct images collapsed onto that one path. A fill now names the node it came from, matching the /images/{name}.png the element already emits so the two agree on the same asset. Past the first fill the index is appended, because the manifest identifies a fill as {nodeId}:fills:{index} and a caller has to be able to tell them apart. The paint loop keeps each paint's original index for that: CSS layers run back to front, and a reference built from the reversed position would name the wrong asset. This is a deliberate departure from the pinned plugin corpus, made on the owner's instruction: the shared name was a limitation at the time rather than an intent. Two goldens encoded it and are updated with their manifest checksums, the only fixture change and two lines of it. The remaining 266 are untouched. --- .../devup-mcp-devup-ui/src/codegen/style.rs | 45 ++++++++++++++++--- .../tests/asset_boundaries.rs | 29 +++++++++++- fixtures/devup-figma-plugin/manifest.json | 4 +- .../upstream-codegen-114-855686da78.snap | 2 +- .../upstream-codegen-246-295f39e09b.snap | 2 +- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index eeb8f7c..75c041c 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -434,28 +434,60 @@ fn background_css( ) -> Option { let view = node.typed_view(); let paints = view.value("fills")?.as_array()?; + // Keep each paint's own index. CSS layers run back to front, so the order + // here is reversed, but an image fill is identified in the asset manifest + // as `{nodeId}:fills:{index}` against the original order — a reference + // built from the reversed position would name the wrong asset. let visible = paints .iter() - .filter(|paint| { + .enumerate() + .filter(|(_, paint)| { paint.get("visible").and_then(Value::as_bool) != Some(false) && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) }) .rev() .collect::>(); let mut css = Vec::new(); - for (index, paint) in visible.iter().enumerate() { - let is_last = index + 1 == visible.len(); - if let Some(value) = paint_css(snapshot, node, paint, is_last, variable_tokens) { + for (layer, (fill_index, paint)) in visible.iter().enumerate() { + let is_last = layer + 1 == visible.len(); + if let Some(value) = paint_css(snapshot, node, paint, *fill_index, is_last, variable_tokens) + { css.push(value); } } (!css.is_empty()).then(|| css.join(", ")) } +/// The file an image fill refers to. +/// +/// Every image fill once resolved to a single hard-coded `/icons/image.png`, +/// which lost three separate things: a raster was pointed at the icon folder, +/// unrelated images from different nodes all claimed the same file and so +/// overwrote one another on disk, and two fills on one node produced the +/// identical URL twice over. The manifest identifies a fill as +/// `{nodeId}:fills:{index}`, so the reference keeps the node's name and, past +/// the first fill, its index — a lone fill keeps the plain +/// `/images/{name}.png` the `` element already emits, so the two agree +/// on the same asset. +fn image_fill_source(node: &RawNode, fill_index: usize) -> String { + let name = node.typed_view().name().unwrap_or("Asset"); + let source = if fill_index == 0 { + format!("/images/{name}.png") + } else { + format!("/images/{name}-{fill_index}.png") + }; + if source.contains(' ') { + format!("'{source}'") + } else { + source + } +} + fn paint_css( snapshot: &Snapshot, node: &RawNode, paint: &Value, + fill_index: usize, last: bool, variable_tokens: &std::collections::BTreeMap, ) -> Option { @@ -482,7 +514,10 @@ fn paint_css( Some("TILE") => "repeat", _ => "center/cover no-repeat", }; - Some(format!("url(/icons/image.png) {fit}")) + Some(format!( + "url({}) {fit}", + image_fill_source(node, fill_index) + )) } "PATTERN" => { let source_id = paint.get("sourceNodeId").and_then(Value::as_str)?; diff --git a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs index 21a86fc..dfaf3ca 100644 --- a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -18,6 +18,31 @@ fn generate(root_id: &str, nodes: Value) -> String { .tsx } +#[test] +fn separate_image_fills_do_not_claim_the_same_file() { + // Two fills on one node are two different images. A single hard-coded + // reference gave both the same URL, so the layered background repeated one + // picture and whichever was exported last overwrote the other on disk. + let tsx = generate( + "1:card", + json!([{ + "id": "1:card", "type": "FRAME", + "fields": { + "name": "Card", "childrenIds": [], + "width": 125.0, "height": 100.0, + "fills": [ + {"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "aaa"}, + {"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "bbb"} + ] + }, + "extra": {}, "fieldErrors": {} + }]), + ); + + assert!(tsx.contains("/images/Card.png"), "{tsx}"); + assert!(tsx.contains("/images/Card-1.png"), "{tsx}"); +} + #[test] fn image_filled_asset_container_preserves_text_children() { let tsx = generate( @@ -42,7 +67,9 @@ fn image_filled_asset_container_preserves_text_children() { ]), ); - assert!(tsx.contains("bg=\"url(/icons/image.png) center/cover no-repeat\"")); + // The fill names the node it came from, so two different images cannot + // claim the same file. The name has a space, hence the quoting. + assert!(tsx.contains("bg=\"url('/images/Book cover.png') center/cover no-repeat\"")); assert!(tsx.contains("Preserved title")); assert!(!tsx.contains("" +"" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap index caa1e44..2aa558d 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-246-295f39e09b.snap @@ -7,7 +7,7 @@ expression: actual return ( Date: Fri, 4 Sep 2026 02:44:39 +0900 Subject: [PATCH 43/69] fix(devup-ui): read a derived padding as the single value it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insets measured from a child's position carry the arithmetic's noise. A 20px badge around a 14.285714px icon leaves 2.857142686 on one side and 2.857143163 on the other, and comparing those as raw floats found them different — so a padding that is one number was written out as four separate sides. Both round to 2.86px, which is what a reader sees and what the plugin emits. The comparison now runs on the values as they will be written, so equal sides collapse into p, py or px again. --- crates/devup-mcp-devup-ui/src/codegen/layout.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 95f7c10..bc179ee 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -525,16 +525,22 @@ fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { string_prop(props, name, px(value)); } }; - if top == right && right == bottom && bottom == left { + // Compare the values as they will be written. Insets measured from a + // child's position carry the arithmetic's noise — a 20px box around a + // 14.285714px child gives 2.857142686 on one side and 2.857143163 on the + // other — and those are the same padding to anyone reading the result. + // Comparing the raw floats split it into four separate sides. + let same = |left: f64, right: f64| px(left) == px(right); + if same(top, right) && same(right, bottom) && same(bottom, left) { push("p", top); } else { - if top == bottom { + if same(top, bottom) { push("py", top); } else { push("pt", top); push("pb", bottom); } - if left == right { + if same(left, right) { push("px", left); } else { push("pl", left); From cfa1b99c3eedb9a23eed38f67b6474e417e55706 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 02:47:59 +0900 Subject: [PATCH 44/69] fix(devup-ui): do not anchor children a folded asset no longer has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An absolutely positioned child needs a positioned ancestor to resolve against, so a frame containing one is given pos="relative". A frame folded into a single asset has no children left in the output — they are baked into the exported icon — so the containing block was established for no one. The clear button on the book cover screen carried it for vectors that never render. Reuses the asset decision the codegen already makes, rather than restating when a subtree collapses. --- .../devup-mcp-devup-ui/src/codegen/layout.rs | 5 +++ .../tests/asset_boundaries.rs | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index bc179ee..0d4239e 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -258,8 +258,13 @@ pub(super) fn push_layout_props( if view.bool("clipsContent") == Some(true) { string_prop(props, "overflow", "hidden"); } + // An absolutely positioned child needs a positioned ancestor to resolve + // against — but a node folded into a single asset has no children left in + // the output, so there is nothing to anchor and the containing block would + // exist for no one. if !embedded_root && !is_page_root + && super::style::asset_kind(snapshot, node).is_none() && view.child_ids().any(|child| { snapshot.nodes.get(child).is_some_and(|child| { child.typed_view().string("layoutPositioning") == Some("ABSOLUTE") diff --git a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs index dfaf3ca..7b1a3c5 100644 --- a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -18,6 +18,44 @@ fn generate(root_id: &str, nodes: Value) -> String { .tsx } +#[test] +fn a_folded_asset_does_not_anchor_children_it_no_longer_has() { + // The vectors inside are baked into the exported icon, so nothing is left + // to position against and a containing block would serve no one. + let tsx = generate( + "1:button", + json!([ + { + "id": "1:button", "type": "FRAME", + "fields": { + "name": "clear button", "childrenIds": ["1:ring"], + "width": 24.0, "height": 24.0 + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:ring", "type": "ELLIPSE", + "fields": { + "name": "Ellipse", "parentId": "1:button", "childrenIds": [], + "layoutPositioning": "ABSOLUTE", + "width": 24.0, "height": 24.0, "x": 0.0, "y": 0.0, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 0.0, "g": 0.0, "b": 0.0}}] + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + assert!( + tsx.contains("/icons/clear button.svg"), + "expected a folded asset: {tsx}" + ); + assert!( + !tsx.contains("pos=\"relative\""), + "a folded asset has no children to anchor: {tsx}" + ); +} + #[test] fn separate_image_fills_do_not_claim_the_same_file() { // Two fills on one node are two different images. A single hard-coded From c4d7ab9f8a8a811d21e3af295435c8a10d03ac8c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 02:56:06 +0900 Subject: [PATCH 45/69] fix(devup-ui): drop the anchor once padding already places the child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frame without auto-layout places its children itself, and pos="relative" kept them resolvable. Deriving the gap around them as padding now puts them where they belong on its own, so on the book cover panel the anchor was left establishing a containing block nothing resolves against. It is still needed wherever nothing could be measured — a child that fills its frame exactly, or one carrying no position at all. An upstream golden covers that case and is what distinguishes the two: the anchor is now conditional on the inset being unmeasurable, rather than removed outright. All 268 stay byte-identical, and both halves are pinned by tests. --- .../src/codegen/component.rs | 6 + .../devup-mcp-devup-ui/src/codegen/layout.rs | 2 +- .../tests/free_placement_anchor.rs | 108 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index 87cdc44..f5bc083 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -1188,10 +1188,16 @@ fn render_node( context.root_layout, depth == 0, ); + // A frame with no auto-layout places its children itself, and this keeps + // them resolvable. Once the gap around them is measurable it is emitted as + // padding instead, which puts them where they belong on its own — so the + // anchor is only still needed where nothing could be measured, as when the + // child fills the frame exactly or carries no position of its own. if !(depth == 0 && context.root_layout == RootLayout::Embedded) && asset.is_none() && view.value("inferredAutoLayout").is_none() && view.string("layoutPositioning") == Some("AUTO") + && layout::children_inset(snapshot, node).is_none() && view.child_ids().any(|child| { snapshot .nodes diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 0d4239e..ac9a622 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -457,7 +457,7 @@ fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: /// describes the frame, so measure it rather than fall back to the frame's own /// padding fields, which linger from whenever it last had a layout and no /// longer place anything. -fn children_inset(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { +pub(super) fn children_inset(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { let view = node.typed_view(); let (width, height) = (view.number("width")?, view.number("height")?); let mut bounds: Option<[f64; 4]> = None; diff --git a/crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs b/crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs new file mode 100644 index 0000000..100b83f --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/free_placement_anchor.rs @@ -0,0 +1,108 @@ +//! A frame without auto-layout places its children itself. +//! +//! Where the gap around them can be measured it becomes padding, which puts +//! them where they belong. Where nothing can be measured — the child fills the +//! frame, or carries no position of its own — the containing block is still +//! what keeps the child resolvable. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +#[test] +fn a_measurable_inset_becomes_padding_and_needs_no_anchor() { + let tsx = generate( + "1:panel", + json!([ + { + "id": "1:panel", "type": "FRAME", + "fields": { + "name": "Panel", "childrenIds": ["1:book"], + "layoutMode": "NONE", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 360.0, "height": 240.0, + "paddingTop": 10.0, "paddingRight": 10.0, + "paddingBottom": 10.0, "paddingLeft": 10.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:book", "type": "FRAME", + "fields": { + "name": "Book", "parentId": "1:panel", "childrenIds": [], + "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 129.0, "height": 200.0, "x": 116.0, "y": 20.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + // The stale padding fields say 10 on every side; the child's real position + // says otherwise, and 116 + 129 + 115 returns the frame's own 360. + assert!(tsx.contains("pl=\"116px\""), "{tsx}"); + assert!(tsx.contains("pr=\"115px\""), "{tsx}"); + assert!(tsx.contains("py=\"20px\""), "{tsx}"); + assert!( + !tsx.contains("p=\"10px\""), + "stale padding must not survive: {tsx}" + ); + assert!( + !tsx.contains("pos=\"relative\""), + "padding already places the child: {tsx}" + ); +} + +#[test] +fn a_child_that_fills_its_frame_keeps_the_anchor() { + let tsx = generate( + "1:icon", + json!([ + { + "id": "1:icon", "type": "FRAME", + "fields": { + "name": "Social", "childrenIds": ["1:layer"], + "layoutMode": "NONE", "layoutPositioning": "AUTO", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 32.0, "height": 32.0, + "fills": [{"type": "SOLID", "visible": true, "color": {"r": 1.0, "g": 1.0, "b": 1.0}}], + "parentId": "0:row", "parentType": "FRAME" + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:layer", "type": "GROUP", + "fields": { + "name": "Layer 2", "parentId": "1:icon", "childrenIds": [], + "layoutPositioning": "AUTO", + "width": 32.0, "height": 32.0 + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + // No position to measure, so nothing became padding and the anchor stays. + assert!( + tsx.contains("pos=\"relative\""), + "an unmeasurable placement still needs its containing block: {tsx}" + ); +} From 3537c44c082f43e05bdad08706228c22e5efe0b0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 03:04:32 +0900 Subject: [PATCH 46/69] fix(devup-ui): stop counting the canvas size the output withholds on purpose A screen's own width and height are deliberately left unsaid, so the result is not pinned to the size it was drawn at. The fidelity report went on counting them, and every screen reported a layout shortfall for the one thing the generator declines to claim: the book cover sat at 121/122 and the two form screens two short each, with the roots' own dimensions named as unmet. The report already excluded these for a component set's children. It now reads the same parent types the layout pass does, including the recorded parent type a root carries, so the two agree on what a canvas is. --- crates/devup-mcp-devup-ui/src/provenance.rs | 15 +++++++- crates/devup-mcp-devup-ui/tests/provenance.rs | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index aff4275..bf76fa6 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -500,8 +500,19 @@ fn layout_field_is_semantic( .child_ids() .any(|child| child == node.id) }); - let component_canvas_dimension = matches!(field, "width" | "height") && component_set_parent; - if component_canvas_dimension { + // A frame sitting on a page or section is the canvas the design was drawn + // on, and its own dimensions are deliberately left unsaid so the result is + // not pinned to that size. Counting them would report a shortfall for + // something the output declines to claim on purpose. Kept in step with the + // same test in `codegen::layout`. + let canvas_parent = component_set_parent + || view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .map(|parent| parent.node_type.as_str()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); + if matches!(field, "width" | "height") && canvas_parent { return false; } match field { diff --git a/crates/devup-mcp-devup-ui/tests/provenance.rs b/crates/devup-mcp-devup-ui/tests/provenance.rs index c377ffe..61b3fd9 100644 --- a/crates/devup-mcp-devup-ui/tests/provenance.rs +++ b/crates/devup-mcp-devup-ui/tests/provenance.rs @@ -769,3 +769,41 @@ fn slice<'a>(tsx: &'a str, entry: &devup_mcp_devup_ui::provenance::ProvenanceEnt let range = entry.generated_range.as_ref().unwrap(); &tsx[range.start..range.end] } + +#[test] +fn a_canvas_root_dimension_is_not_counted_as_an_unmet_layout_fact() { + // The screen's own size is deliberately left unsaid so the result is not + // pinned to the width it was drawn at. Counting it would report a + // shortfall for something the output declines to claim on purpose. + let snapshot = Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["1:1".to_owned()], + nodes: [node( + "1:1", + "FRAME", + json!({ + "name": "Screen", "childrenIds": [], "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 360, "height": 800, + "parentId": "0:page", "parentType": "SECTION" + }), + )] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(), + diagnostics: Vec::new(), + }; + + let output = generate_component(&snapshot, "1:1", &CodegenOptions::default()).expect("codegen"); + let report = validate_fidelity(&snapshot, "1:1", &output).expect("fidelity"); + + assert!( + !report + .uncovered_layout + .iter() + .any(|entry| entry.ends_with("#width") || entry.ends_with("#height")), + "canvas geometry must not be reported as unmet: {:?}", + report.uncovered_layout + ); +} From c761d1dff9be994f592b1fa514ece4c53a395e31 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 04:10:25 +0900 Subject: [PATCH 47/69] fix(figma): collect defaultVariant, and hold the collector to what the code reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A component set's default variant is chosen by matching its name, and the registration output carries it. Neither could work against a live file, because defaultVariant was never in the collected field manifest — the read always found nothing. The pinned corpus carries it, so all 268 goldens passed while the live path silently took the fallback. This is the second time today the same shape appeared: text truncation defaulted to on for exactly the same reason. A fixture cannot show it, since the capture holds the field either way, so the gap is now an enforced invariant. Every node field read through the typed accessors must appear in the manifest, or be listed as one of the names that never comes from a node — scripts' own additions, envelope records, and the explore path, which reads the node directly. Removing either defaultVariant or textTruncation from the manifest now fails that test, which is how it was checked. Four corpus fields remain uncollected on purpose. layoutAlign is redundant: all 77 STRETCH nodes also carry the layoutSizing fields that are read instead. counterAxisSpacing is 0 everywhere and no layout in the corpus wraps. mainComponentId is read by nothing. fontWeight is left alone deliberately — the segment path supplies it and collecting the node-level value would change which one wins, with no observed defect to justify it. --- .../src/plugin_api_manifest.json | 2 +- .../tests/manifest_covers_readers.rs | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 crates/devup-mcp-figma/tests/manifest_covers_readers.rs diff --git a/crates/devup-mcp-figma/src/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index 5bcd048..838d436 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -2,7 +2,7 @@ "arcData", "backgroundStyleId", "blendMode", "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", "componentProperties", "componentPropertyDefinitions", "componentPropertyReferences", "constraints", "cornerRadius", - "counterAxisAlignItems", "dashPattern", "effects", "effectStyleId", "fills", + "counterAxisAlignItems", "dashPattern", "defaultVariant", "effects", "effectStyleId", "fills", "fillStyleId", "fontName", "fontSize", "gridColumnAnchorIndex", "gridColumnCount", "gridColumnGap", "gridRowAnchorIndex", "gridRowCount", "gridRowGap", "gridStyleId", "height", "inferredAutoLayout", "isAsset", "isMask", "itemSpacing", diff --git a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs new file mode 100644 index 0000000..1836039 --- /dev/null +++ b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs @@ -0,0 +1,87 @@ +//! A field the code reads must be a field the collector asks Figma for. +//! +//! Twice now a rule has been written against a node field that was never +//! collected, so it read nothing and silently took the wrong branch: text +//! truncation defaulted to on because an absent value is not `DISABLED`, and a +//! component set could not find its default variant by name. Both looked +//! correct in the pinned corpus, whose captures carry the fields, and were only +//! wrong against a live file — which is exactly the gap a fixture cannot show. + +use std::{collections::BTreeSet, fs, path::Path}; + +/// Names that are read through the same accessors but never come from a Figma +/// node, so the manifest has nothing to say about them. +const NOT_NODE_FIELDS: &[&str] = &[ + // Written by our own scripts onto the node record. + "parentId", + "parentType", + "childrenIds", + "styledTextSegments", + // Envelope, pagination and probe records, not nodes. + "breadcrumb", + "childCount", + "complete", + "devupTokens", + "directChildCount", + "estimatedSerializedBytes", + "pageChildIndex", + "projectionTruncated", + "subtreeNodeCount", + "textPreview", + // Read only on the explore path, whose script reads the node directly + // rather than through the manifest. + "absoluteBoundingBox", + "annotations", +]; + +fn read_sources(directory: &Path, into: &mut String) { + for entry in fs::read_dir(directory).expect("source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + read_sources(&path, into); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + into.push_str(&fs::read_to_string(&path).expect("source file")); + into.push('\n'); + } + } +} + +#[test] +fn every_field_the_code_reads_is_a_field_the_collector_requests() { + let crates = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let mut source = String::new(); + read_sources(&crates.join("devup-mcp-figma/src"), &mut source); + read_sources(&crates.join("devup-mcp-devup-ui/src"), &mut source); + + let manifest: BTreeSet = serde_json::from_str( + &fs::read_to_string(crates.join("devup-mcp-figma/src/plugin_api_manifest.json")) + .expect("manifest"), + ) + .expect("manifest is a list of field names"); + + // `view.string("x")` and friends are how a node field is read. + let mut missing = BTreeSet::new(); + for accessor in [".value(\"", ".string(\"", ".number(\"", ".bool(\""] { + let mut rest = source.as_str(); + while let Some(at) = rest.find(accessor) { + rest = &rest[at + accessor.len()..]; + let Some(end) = rest.find('"') else { break }; + let field = &rest[..end]; + if !field.is_empty() + && field.chars().all(|c| c.is_ascii_alphanumeric()) + && !manifest.contains(field) + && !NOT_NODE_FIELDS.contains(&field) + { + missing.insert(field.to_owned()); + } + } + } + + assert!( + missing.is_empty(), + "these node fields are read but never collected, so they are always \ + absent against a live file: {missing:?}. Add them to \ + plugin_api_manifest.json, or list them in NOT_NODE_FIELDS with the \ + reason they are not node fields." + ); +} From ceb8b69fcb505005c3271cce53e75880141d3a67 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 08:28:45 +0900 Subject: [PATCH 48/69] fix(devup-ui): put rasters with the images, and state a pinned size only once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A png is an image and an svg is an icon, which is the split every asset reference follows — except pattern fills, which sent both to the icon folder. The corpus only patterns with vectors, so all 268 goldens are unchanged; a test covers the raster case that had no coverage. Separately, an absolutely positioned node was restating its pinned size even where the padding derived from its children already accounted for it: 2.86px around a 14.29px icon comes back to the 20px Figma pinned, and boxSize said it a second time. It is still restated where nothing else would give the box a size, which is the folded-asset case — its children are baked into the exported image and never laid out, so no padding is derived from them either. Both halves are now the one question "was a padding derived", asked in one place so the two answers cannot disagree. That helper first read inferredAutoLayout with is_some, which treats Figma's explicit null for an uninferrable frame as a layout — the modal overlay lost the inset that centres its dialog. It now tests for an object, as the code it replaced did. --- .../devup-mcp-devup-ui/src/codegen/layout.rs | 42 +++++-- .../devup-mcp-devup-ui/src/codegen/style.rs | 15 ++- .../tests/asset_boundaries.rs | 39 +++++++ .../tests/pinned_size_restatement.rs | 110 ++++++++++++++++++ 4 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index ac9a622..8b73620 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -80,10 +80,17 @@ pub(super) fn push_layout_props( // An absolutely positioned node is out of flow, so nothing constrains // it from the outside and the branches above may leave it sizeless, // expecting its children to define the box. That is wrong whenever - // Figma pinned the size: a folded asset has no children left to - // measure, and a container whose children are smaller than the frame - // shrinks to the wrong size. Restate what Figma fixed. - if fixed_w && fixed_h && width.is_none() && height.is_none() { + // Figma pinned the size and nothing else accounts for it — a folded + // asset has no children left to measure at all. Where the gap around + // the children became padding, though, that padding and the content + // already add back up to the frame, and restating the size only says + // it twice. + if fixed_w + && fixed_h + && width.is_none() + && height.is_none() + && derived_padding(snapshot, node).is_none() + { width = view.number("width").map(px); height = view.number("height").map(px); } @@ -457,6 +464,29 @@ fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: /// describes the frame, so measure it rather than fall back to the frame's own /// padding fields, which linger from whenever it last had a layout and no /// longer place anything. +/// The padding this node will actually be given from its children's placement. +/// +/// A folded asset is excluded: its children are baked into the exported image +/// and never laid out, so measuring a gap around them would describe a box +/// nothing lives in. +pub(super) fn derived_padding(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { + let view = node.typed_view(); + // Figma reports a frame it cannot infer a layout for as an explicit null, + // so presence alone does not mean there is a layout to read. + if view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_some() + || view.string("layoutMode") != Some("NONE") + { + return None; + } + if super::style::asset_kind(snapshot, node).is_some() { + return None; + } + children_inset(snapshot, node) +} + pub(super) fn children_inset(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { let view = node.typed_view(); let (width, height) = (view.number("width")?, view.number("height")?); @@ -494,9 +524,7 @@ pub(super) fn children_inset(snapshot: &Snapshot, node: &RawNode) -> Option<[f64 fn push_padding(snapshot: &Snapshot, node: &RawNode, props: &mut Vec) { let view = node.typed_view(); let inferred = view.value("inferredAutoLayout").and_then(Value::as_object); - let derived = (inferred.is_none() && view.string("layoutMode") == Some("NONE")) - .then(|| children_inset(snapshot, node)) - .flatten(); + let derived = derived_padding(snapshot, node); let get = |name: &str| { inferred .and_then(|value| value.get(name)) diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index 75c041c..cc41107 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -525,10 +525,17 @@ fn paint_css( let name = source .and_then(|node| node.typed_view().name()) .unwrap_or("pattern"); - let extension = source + // A raster belongs with the images and a vector with the icons, + // which is the split every other asset reference follows. This one + // sent a png to the icon folder. + let raster = source .and_then(|node| asset_kind(snapshot, node)) - .map(|kind| if kind == AssetKind::Png { "png" } else { "svg" }) - .unwrap_or("svg"); + .is_some_and(|kind| kind == AssetKind::Png); + let (folder, extension) = if raster { + ("images", "png") + } else { + ("icons", "svg") + }; let spacing = paint.get("spacing").and_then(Value::as_object); let x = spacing .and_then(|value| value.get("x")) @@ -560,7 +567,7 @@ fn paint_css( .collect::>() .join(" "); Some(format!( - "url(/icons/{name}.{extension}){} repeat", + "url(/{folder}/{name}.{extension}){} repeat", if position.is_empty() { String::new() } else { diff --git a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs index 7b1a3c5..727fd62 100644 --- a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -56,6 +56,45 @@ fn a_folded_asset_does_not_anchor_children_it_no_longer_has() { ); } +#[test] +fn a_raster_pattern_is_referenced_from_the_image_folder() { + // A png is an image and an svg is an icon, which is the split every other + // asset reference follows. Pattern fills sent both to the icon folder. + let tsx = generate( + "1:wall", + json!([ + { + "id": "1:wall", "type": "FRAME", + "fields": { + "name": "Wall", "childrenIds": [], + "width": 200.0, "height": 100.0, + "fills": [{ + "type": "PATTERN", "visible": true, + "sourceNodeId": "1:tile", + "spacing": {"x": 0.0, "y": 0.0} + }] + }, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:tile", "type": "FRAME", + "fields": { + "name": "Tile", "childrenIds": [], "isAsset": true, + "width": 20.0, "height": 20.0, + "fills": [{"type": "IMAGE", "visible": true, "scaleMode": "FILL", "imageHash": "h"}] + }, + "extra": {}, "fieldErrors": {} + } + ]), + ); + + assert!( + tsx.contains("/images/Tile.png"), + "a raster pattern is an image: {tsx}" + ); + assert!(!tsx.contains("/icons/Tile"), "{tsx}"); +} + #[test] fn separate_image_fills_do_not_claim_the_same_file() { // Two fills on one node are two different images. A single hard-coded diff --git a/crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs b/crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs new file mode 100644 index 0000000..eb8c206 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/pinned_size_restatement.rs @@ -0,0 +1,110 @@ +//! An absolutely positioned node states its pinned size only when nothing +//! else accounts for it. +//! +//! Where the gap around the children became padding, that padding and the +//! content already add back up to the frame. Where the node was folded into a +//! single asset there are no children at all, so the size is the only thing +//! left to give it one. + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{SnapshotChunk, merge_chunks}; +use serde_json::{Value, json}; + +fn generate(root_id: &str, nodes: Value) -> String { + let chunk: SnapshotChunk = serde_json::from_value(json!({ + "fileKey": "file-key", + "version": "1", + "rootIds": [root_id], + "nodes": nodes, + "diagnostics": [] + })) + .expect("synthetic snapshot"); + let snapshot = merge_chunks(vec![chunk]).expect("snapshot"); + + generate_component(&snapshot, root_id, &CodegenOptions::default()) + .expect("codegen") + .tsx +} + +fn card(child: Value) -> Value { + json!([ + { + "id": "1:card", "type": "FRAME", + "fields": { + "name": "Card", "childrenIds": ["1:badge"], + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 125.0, "height": 100.0, + "parentId": "0:page", "parentType": "SECTION" + }, + "extra": {}, "fieldErrors": {} + }, + child, + { + "id": "1:inner", "type": "FRAME", + "fields": { + "name": "Icons", "parentId": "1:badge", "childrenIds": [], + "width": 14.285714149475098, "height": 14.285714149475098, + "x": 2.857142686843872, "y": 2.857142686843872 + }, + "extra": {}, "fieldErrors": {} + } + ]) +} + +#[test] +fn a_padded_container_does_not_also_restate_its_size() { + let tsx = generate( + "1:card", + card(json!({ + "id": "1:badge", "type": "FRAME", + "fields": { + "name": "Badge", "parentId": "1:card", "childrenIds": ["1:inner"], + "layoutMode": "NONE", "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 20.0, "height": 20.0, "x": 6.0, "y": 6.0, + "cornerRadius": 1000.0, + "constraints": {"horizontal": "MIN", "vertical": "MIN"} + }, + "extra": {}, "fieldErrors": {} + })), + ); + + // 2.86 + 14.29 + 2.86 comes back to the 20px Figma pinned. + assert!(tsx.contains("p=\"2.86px\""), "{tsx}"); + assert!( + !tsx.contains("boxSize=\"20px\""), + "padding and content already give the size: {tsx}" + ); +} + +#[test] +fn a_folded_asset_still_states_the_size_it_was_pinned_to() { + // Its children are baked into the exported image, so no padding is derived + // and nothing else would give this box a size. + let tsx = generate( + "1:card", + card(json!({ + "id": "1:badge", "type": "FRAME", + "fields": { + "name": "Logo", "parentId": "1:card", "childrenIds": ["1:inner"], + "layoutMode": "NONE", "layoutPositioning": "ABSOLUTE", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 24.0, "height": 9.0, "x": 93.0, "y": 79.0, + "isAsset": true, + "constraints": {"horizontal": "MAX", "vertical": "MAX"} + }, + "extra": {}, "fieldErrors": {} + })), + ); + + assert!( + tsx.contains("w=\"24px\""), + "a folded asset needs its size: {tsx}" + ); + assert!(tsx.contains("h=\"9px\""), "{tsx}"); + assert!( + !tsx.contains("p=\"2.86px\""), + "an asset's hidden children are not a padding: {tsx}" + ); +} From e67ac650fab3ecf497b3ff0903ebd1f0003abe13 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 08:35:03 +0900 Subject: [PATCH 49/69] test(devup-ui): replay real screens without spending the Figma allowance Checking a codegen change against a real design cost about fifteen tool calls each time, against a daily allowance of two hundred, and today it ran out mid-session. Five screens are now captured once and replayed for free, which is what makes it practical to see a change against real designs rather than only synthetic nodes. The captures are scratch and git-ignores them: the pinned corpus still decides correctness. With nothing captured the test says so and passes, so a fresh checkout is never blocked on it. It earned its place immediately. Two things surfaced that synthetic nodes and the corpus both missed: The harness first converted with default options and reported forty unaccounted layout facts. The server inlines instances; without that an instance stays a component reference and everything inside it goes unemitted. The harness now converts the way the server does. The remaining two were real: having stopped restating a size that derived padding already accounts for, the fidelity report went on counting that size as unmet. It now applies the same test the emitter does, so the two agree. All five screens replay with every layout fact and every character accounted for. --- .gitignore | 5 + .../devup-mcp-devup-ui/src/codegen/layout.rs | 2 +- crates/devup-mcp-devup-ui/src/codegen/mod.rs | 1 + crates/devup-mcp-devup-ui/src/provenance.rs | 12 ++- .../devup-mcp-devup-ui/tests/local_screens.rs | 92 +++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/local_screens.rs diff --git a/.gitignore b/.gitignore index 0d8f70c..33364db 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ .omc/ .omo/ +# Snapshots captured from a live Figma file to iterate on codegen without +# spending the tool-call allowance. Scratch, not ground truth: the pinned +# corpus under fixtures/devup-figma-plugin is what decides correctness. +/fixtures/local-screens/ + diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 8b73620..7ed9d6b 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -469,7 +469,7 @@ fn push_auto_layout(snapshot: &Snapshot, node: &RawNode, component: &str, props: /// A folded asset is excluded: its children are baked into the exported image /// and never laid out, so measuring a gap around them would describe a box /// nothing lives in. -pub(super) fn derived_padding(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { +pub(crate) fn derived_padding(snapshot: &Snapshot, node: &RawNode) -> Option<[f64; 4]> { let view = node.typed_view(); // Figma reports a frame it cannot infer a layout for as an explicit null, // so presence alone does not mean there is a layout to read. diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index 27236bc..5acabb8 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -15,4 +15,5 @@ pub use component::{ generate_inlined_component_instance, generate_legacy_component, generate_node, normalize_component_name, render_component_registration_snapshot, render_component_source, }; +pub(crate) use layout::derived_padding; pub(crate) use style::asset_kind; diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index bf76fa6..4422a86 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -4,7 +4,7 @@ use devup_mcp_figma::{DevupError, ErrorCode, FidelityImpact, Snapshot, discover_ use serde::{Deserialize, Serialize}; use serde_json::json; -use crate::codegen::{CodegenOutput, asset_kind}; +use crate::codegen::{CodegenOutput, asset_kind, derived_padding}; const START: &str = "\u{e000}DEVUP_PROVENANCE_START:"; const END: &str = "\u{e000}DEVUP_PROVENANCE_END:"; @@ -515,6 +515,16 @@ fn layout_field_is_semantic( if matches!(field, "width" | "height") && canvas_parent { return false; } + // An out-of-flow node whose children's inset became padding takes its size + // from that padding plus its content, so the size is not restated and + // counting it would report a shortfall for something said another way. + // Kept in step with the same test in `codegen::layout`. + if matches!(field, "width" | "height") + && view.string("layoutPositioning") == Some("ABSOLUTE") + && derived_padding(snapshot, node).is_some() + { + return false; + } match field { "layoutMode" => matches!(view.string(field), Some("HORIZONTAL" | "VERTICAL" | "GRID")), "layoutPositioning" => view.string(field) == Some("ABSOLUTE"), diff --git a/crates/devup-mcp-devup-ui/tests/local_screens.rs b/crates/devup-mcp-devup-ui/tests/local_screens.rs new file mode 100644 index 0000000..010ec73 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/local_screens.rs @@ -0,0 +1,92 @@ +//! Runs codegen over snapshots captured from a live Figma file. +//! +//! Figma meters tool calls, and one export spends about fifteen of them, so +//! checking a codegen change against a real screen used to cost allowance +//! every time — and ran out. These snapshots are captured once and replayed +//! for free, which is what makes it practical to see a change against real +//! designs rather than only synthetic nodes. +//! +//! They are scratch, not ground truth: the pinned corpus under +//! `fixtures/devup-figma-plugin` decides correctness, and this directory is +//! ignored by git. With nothing captured the test simply reports that and +//! passes, so a fresh checkout is never blocked on it. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::{ + codegen::{CodegenOptions, generate_component}, + provenance::validate_fidelity, +}; +use devup_mcp_figma::Snapshot; + +fn captured() -> Vec<(String, String, Snapshot)> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/local-screens"); + let Ok(entries) = fs::read_dir(&root) else { + return Vec::new(); + }; + let mut screens = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(&path).expect("captured screen"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("captured screen is json"); + let snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("captured snapshot"); + let label = value["label"].as_str().unwrap_or("screen").to_owned(); + let root_id = snapshot.roots.first().cloned().expect("a captured root"); + screens.push((label, root_id, snapshot)); + } + screens +} + +#[test] +fn every_captured_screen_converts_and_accounts_for_itself() { + let screens = captured(); + if screens.is_empty() { + eprintln!( + "no captured screens in fixtures/local-screens; skipping. \ + Capture them from a live file to exercise this." + ); + return; + } + + let mut report = Vec::new(); + for (label, root_id, snapshot) in &screens { + // Matches how the server converts a screen. Without inlining, an + // instance stays a component reference and everything inside it goes + // unemitted, which reads as a huge shortfall that the real path does + // not have. + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + }; + let output = generate_component(snapshot, root_id, &options) + .unwrap_or_else(|error| panic!("{label} failed to convert: {error:?}")); + let fidelity = validate_fidelity(snapshot, root_id, &output) + .unwrap_or_else(|error| panic!("{label} failed fidelity: {error:?}")); + + assert!(fidelity.syntax_valid, "{label} produced unparseable TSX"); + assert!( + fidelity.uncovered_layout.is_empty(), + "{label} leaves layout facts unaccounted for: {:?}", + fidelity.uncovered_layout + ); + assert_eq!( + fidelity.text.covered, fidelity.text.total, + "{label} dropped text" + ); + + report.push(format!( + " {label}: {} chars, layout {}/{}, text {}/{}", + output.tsx.len(), + fidelity.layout.covered, + fidelity.layout.total, + fidelity.text.covered, + fidelity.text.total + )); + } + + eprintln!("captured screens:\n{}", report.join("\n")); +} From 575f37ca665a3988733095a5fbf1b1bd37cdd48a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 11:23:38 +0900 Subject: [PATCH 50/69] fix(figma): let a Section without screens still offer what it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exploring from a page id crashed outright: a page has no `visible`, and Figma throws on reading a property a node does not have rather than returning undefined. Past that, a Section is answered with the screens inside it, because converting one whole is too much. A Section holding none — a catalogue of small cases, a page of components, anything not phone or desktop shaped — came back selection_required with an empty list, which tells the caller to choose from nothing and leaves no way forward. The devup-Test file is entirely made of such Sections, so none of it could be reached. Its own children are the honest answer there: they are what the Section actually offers. Both halves are needed, because the script decides what the snapshot carries and the Rust index decides what is offered from it — filtering in one place only would either withhold the data or discard it again. The screen-shape search is unchanged and still runs first, so a Section that does hold screens is answered exactly as before. Verified against the file: the Gradient section went from zero candidates to fourteen, the seven cases and the seven frames carrying their expected code. --- crates/devup-mcp-figma/src/scripts/explore.js | 5 ++++- .../devup-mcp-figma/src/scripts/section_index.js | 10 ++++++++++ crates/devup-mcp-figma/src/section.rs | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/devup-mcp-figma/src/scripts/explore.js b/crates/devup-mcp-figma/src/scripts/explore.js index fb14312..23c2d8b 100644 --- a/crates/devup-mcp-figma/src/scripts/explore.js +++ b/crates/devup-mcp-figma/src/scripts/explore.js @@ -194,7 +194,10 @@ const compact = [...included.values()] childCount: "children" in node ? node.children.length : 0, textPreview: textPreview(node), pageChildIndex: pageChildIndex >= 0 ? pageChildIndex : null, - visible: node.visible !== false, + // A page or the document itself has no `visible`, and Figma throws on + // reading a property a node does not have rather than returning + // undefined — so exploring from a page id failed outright. + visible: !("visible" in node) || node.visible !== false, breadcrumb: breadcrumb(node), }, extra: {}, diff --git a/crates/devup-mcp-figma/src/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index f81e09e..5db0332 100644 --- a/crates/devup-mcp-figma/src/scripts/section_index.js +++ b/crates/devup-mcp-figma/src/scripts/section_index.js @@ -89,6 +89,16 @@ for (let index = 0; index < queue.length && traversalCount < MAX_TRAVERSED_NODES } if ("children" in node) queue.push(...node.children); } +// Nothing screen shaped inside means the search found nothing to offer, and +// the caller is left selecting from an empty list. The Section's own children +// are what it actually holds, so carry them instead — a catalogue of small +// cases or a page of components has no screens by this measure. +if (candidateNodes.length === 0 && "children" in section) { + for (const node of section.children) { + const box = bounds(node); + if (box && node.visible !== false) candidateNodes.push({ node, box }); + } +} candidateNodes.sort((left, right) => left.box.y - right.box.y || left.box.x - right.box.x diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index 7158a12..a49bc77 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -159,6 +159,21 @@ pub fn build_section_index( screen_nodes.push(explore); } } + // A Section is answered with the screens inside it, because converting one + // whole is too much. A Section holding none — a catalogue of small cases, a + // page of components, anything not phone or desktop shaped — produced an + // empty list, and the caller was told to select from nothing with no way + // forward. Its own children are the honest answer there: they are what the + // Section actually offers. + if screen_nodes.is_empty() { + screen_nodes = section + .typed_view() + .child_ids() + .filter_map(|child_id| snapshot.nodes.get(child_id)) + .filter_map(|child| ExploreNode::try_from(child).ok()) + .filter(|child| child.visible) + .collect(); + } let screen_ids = screen_nodes .iter() .map(|node| node.node_id.clone()) From b1817fd17d7166124bc8490467b15b8ec660067a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 11:35:45 +0900 Subject: [PATCH 51/69] fix(figma): stop rejecting a multi-chunk collection over its own cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converting a whole Section — the documented way, via allScreens or frameIds — failed every time with "Different snapshot data was returned for the same Figma node". The node was __DEVUP_SNAPSHOT_CURSOR__, each chunk's own pagination state, compared as though it described the design. Two chunks disagreeing on complete, nextOffset and totalNodes is the one thing they are certain to do, so any collection arriving in more than one chunk was refused. The merge now passes over it, and the check that remains is about real nodes. That check also says which node and which fields disagree: without naming them there was nothing to act on, and it named this one immediately. parentType is now keyed on the parent's type rather than on being a requested root. A multi-root collection is split into batches with different root sets, so the same node would carry the field in one batch and not in another and be rejected as two different nodes. Keying it on the parent means a node looks the same however it is reached, and only frames sitting on a page, section or component set carry it at all — which is the only case that reads it. Verified against the devup-Test file: the Grid and Gradient sections now collect, where both previously failed outright. --- .../src/scripts/fast_snapshot.js | 15 ++++++++- .../devup-mcp-figma/src/scripts/snapshot.js | 11 ++++++- crates/devup-mcp-figma/src/snapshot.rs | 33 +++++++++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 0d6832f..1bc41e6 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -162,7 +162,20 @@ function snapshotNode(node) { // drawn on. Every other node's parent is collected and can be read directly, // so recording it there would be repetition — and repeated across a whole // screen it was enough to push the payload into chunked delivery. - if (node.parent && requestedRootIds.includes(node.id)) { + // Only a frame sitting directly on a page, section or component set needs + // this: its parent is outside the collected subtree, so the id alone says + // nothing, and the type is what decides whether its width is a real + // constraint or the canvas it was drawn on. Keyed on the parent's type + // rather than on being a requested root, because a multi-root collection is + // split into batches with different root sets — the same node would then + // carry the field in one batch and not another, and merging rejects a node + // that arrives two different ways. + if ( + node.parent && + (node.parent.type === "PAGE" || + node.parent.type === "SECTION" || + node.parent.type === "COMPONENT_SET") + ) { fields.parentType = node.parent.type; } const childrenIds = "children" in node ? node.children.map((child) => child.id) : []; diff --git a/crates/devup-mcp-figma/src/scripts/snapshot.js b/crates/devup-mcp-figma/src/scripts/snapshot.js index 922aeab..30c657a 100644 --- a/crates/devup-mcp-figma/src/scripts/snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/snapshot.js @@ -165,7 +165,16 @@ function snapshotNode(node) { // whether the root's width is a real constraint or merely the canvas the // design was drawn on. Every other node's parent is collected and can be // read directly. - if (node.parent && node.id === root.id) fields.parentType = node.parent.type; + // Keyed on the parent's type rather than on being the requested root, so a + // node carries the same fields however it is reached. See fast_snapshot.js. + if ( + node.parent && + (node.parent.type === "PAGE" || + node.parent.type === "SECTION" || + node.parent.type === "COMPONENT_SET") + ) { + fields.parentType = node.parent.type; + } fields.childrenIds = "children" in node ? node.children.map((child) => child.id) : []; for (const name of propertyNames(node)) { diff --git a/crates/devup-mcp-figma/src/snapshot.rs b/crates/devup-mcp-figma/src/snapshot.rs index 20b7419..a6899c5 100644 --- a/crates/devup-mcp-figma/src/snapshot.rs +++ b/crates/devup-mcp-figma/src/snapshot.rs @@ -424,7 +424,7 @@ pub fn merge_chunks(chunks: Vec) -> Result let version = first.version.clone(); let mut roots = Vec::new(); let mut root_set = BTreeSet::new(); - let mut nodes = BTreeMap::new(); + let mut nodes: BTreeMap = BTreeMap::new(); let mut diagnostics = Vec::new(); for chunk in chunks { @@ -441,12 +441,41 @@ pub fn merge_chunks(chunks: Vec) -> Result } } for node in chunk.nodes { + // The cursor is each chunk's own pagination state, not a node of + // the design. Comparing it as one meant any collection arriving in + // more than one chunk — every multi-root Section export — was + // rejected for the cursors disagreeing, which is the one thing they + // are certain to do. + if node.id == SNAPSHOT_CURSOR_ID { + continue; + } if let Some(existing) = nodes.get(&node.id) { if existing != &node { - return Err(DevupError::new( + // Which node, and which fields disagree. A collection split + // across batches can reach the same node two ways, and + // without naming the difference there is nothing to act on. + let differing = existing + .fields + .keys() + .chain(node.fields.keys()) + .collect::>() + .into_iter() + .filter(|field| { + existing.fields.get(field.as_str()) != node.fields.get(field.as_str()) + }) + .take(12) + .cloned() + .collect::>(); + return Err(DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, "Different snapshot data was returned for the same Figma node.", true, + serde_json::json!({ + "nodeId": node.id, + "nodeType": node.node_type, + "typeChanged": existing.node_type != node.node_type, + "differingFields": differing, + }), )); } } else { From 9faf25fd6b930653b685655ac9f67c2da2c332f5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 11:39:42 +0900 Subject: [PATCH 52/69] test(devup-ui): compare generated code against what each case states The devup-Test file writes, beside every case, the devup-ui it is meant to produce. That is ground truth of a kind the pinned corpus cannot be: the corpus records what the plugin did, this records what the case is for. Captured sections are replayed offline, so the comparison costs no allowance. Run against the Gradient section it reports seven differences and none of them is a defect, which is the point of reporting rather than asserting. In every case the corpus holds exactly what we emit: -47deg where the note reads 313deg, 43% 21% where it reads 33.84% 33.84%, conic stops in percent where the note uses degrees. Those are the same gradients said two ways, and the shapes really do clip, so the overflow the notes omit belongs there. Normalising toward the notes would have broken three goldens and moved away from the reference implementation. The header says so, so the next reader checks the corpus before treating a difference as something to fix. --- .../tests/testcase_expectations.rs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 crates/devup-mcp-devup-ui/tests/testcase_expectations.rs diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs new file mode 100644 index 0000000..e09eb96 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -0,0 +1,157 @@ +//! Compares generated code against the code the design itself carries. +//! +//! The devup-Test file states, next to each case, the devup-ui it is meant to +//! produce. That makes it ground truth of a kind the pinned corpus cannot be: +//! the corpus records what the plugin did, this records what the case is for. +//! +//! Captures live in `fixtures/local-screens/testcase-*.json` and are ignored by +//! git — with none present the test reports that and passes. +//! +//! It reports rather than asserts, and the reason matters. The stated code is +//! written by hand and describes the intent, not the output: against the +//! Gradient section every case differs, and in every one the pinned corpus +//! holds exactly what we emit — `-47deg` where the note reads `313deg`, `43% +//! 21%` where it reads `33.84% 33.84%`. Those are the same gradients said two +//! ways, and normalising toward the note would have broken three goldens and +//! moved away from the reference implementation. +//! +//! So a difference here is a question: check the corpus before treating it as +//! a defect. Where the corpus agrees with us the note is shorthand; where it +//! agrees with the note, that is ours to fix. + +use std::{collections::BTreeMap, fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; + +/// The JSX inside `export function X() { return ( ... ) }`. +fn body(tsx: &str) -> String { + let after_return = tsx.find("return (").map(|at| at + "return (".len()); + let start = after_return + .and_then(|from| tsx[from..].find('<').map(|at| from + at)) + .unwrap_or(0); + let end = tsx.rfind(");").unwrap_or(tsx.len()); + normalise(&tsx[start..end.max(start)]) +} + +/// Collapses the formatting so a comparison is about the code, not its layout. +fn normalise(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +struct Case { + expected: String, + node_id: String, +} + +fn cases(snapshot: &Snapshot) -> Vec { + let node = |id: &str| snapshot.nodes.get(id); + let x_of = |id: &str| { + node(id) + .and_then(|n| n.typed_view().number("x")) + .unwrap_or(f64::MAX) + }; + + // A case frame holds the shape; the Code frame beside it holds the text. + let mut roots: Vec<&String> = snapshot.roots.iter().collect(); + roots.sort_by(|left, right| x_of(left).total_cmp(&x_of(right))); + + let mut expectations: Vec<(f64, String)> = Vec::new(); + let mut shapes: Vec<(f64, String)> = Vec::new(); + for root in roots { + let Some(raw) = node(root) else { continue }; + let view = raw.typed_view(); + let text = view + .child_ids() + .filter_map(|child| snapshot.nodes.get(child)) + .find_map(|child| { + child + .typed_view() + .value("characters") + .and_then(|value| value.as_str()) + .filter(|text| text.trim_start().starts_with('<')) + .map(str::to_owned) + }); + match text { + Some(text) => expectations.push((x_of(root), normalise(&text))), + None => { + if let Some(shape) = view.child_ids().next() { + shapes.push((x_of(root), shape.to_owned())); + } + } + } + } + + // Each expectation belongs to the nearest shape to its right. + expectations + .into_iter() + .filter_map(|(at, expected)| { + shapes + .iter() + .filter(|(shape_at, _)| *shape_at >= at) + .min_by(|left, right| left.0.total_cmp(&right.0)) + .map(|(_, node_id)| Case { + expected, + node_id: node_id.clone(), + }) + }) + .collect() +} + +#[test] +fn generated_code_matches_what_each_case_states() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/local-screens"); + let Ok(entries) = fs::read_dir(&root) else { + eprintln!("no captures; skipping"); + return; + }; + + let mut agreed = 0usize; + let mut differed: BTreeMap> = BTreeMap::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|v| v.to_str()).unwrap_or(""); + if !name.starts_with("testcase-") { + continue; + } + let raw = fs::read_to_string(&path).expect("captured section"); + let value: serde_json::Value = + serde_json::from_str(&raw).expect("captured section is json"); + let snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("captured snapshot"); + let label = value["label"].as_str().unwrap_or(name).to_owned(); + + for case in cases(&snapshot) { + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + }; + let actual = match generate_component(&snapshot, &case.node_id, &options) { + Ok(output) => body(&output.tsx), + Err(error) => format!(""), + }; + if actual == case.expected { + agreed += 1; + } else { + differed + .entry(label.clone()) + .or_default() + .push((case.expected, actual)); + } + } + } + + let total = agreed + differed.values().map(Vec::len).sum::(); + if total == 0 { + eprintln!("no captured test cases; skipping"); + return; + } + eprintln!("cases: {total}, matching what the design states: {agreed}"); + for (label, entries) in &differed { + eprintln!("\n=== {label}"); + for (expected, actual) in entries { + eprintln!(" states : {expected}"); + eprintln!(" we emit: {actual}\n"); + } + } +} From 78cccbcffc3ad13c0ede0603182a30f1d76806db Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 11:54:51 +0900 Subject: [PATCH 53/69] fix(figma): stop a Section from hiding every case behind its notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screen shape is a guess for finding screens on a page that has no grouping. A Section is grouping, already explicit, and the guess applied there answered with whatever happened to measure like a phone. A Section of small cases annotated with tall notes turns it upside down: the notes pass and the cases do not, so the index offered the notes and hid every case — an answer that looked complete and was not, which is worse than the empty list a Section of cases used to give. What the Section holds is what it offers, so its children now stand alongside the screens found within it. Text lying directly on a Section stays out: that is how a Section is labelled, and content text sits inside a frame. Reading the cases needed the comparison to pair them, and its rule held for one layout only — a case sits above its note in one section and beside it in the next, and it is the root itself as often as it is wrapped in a frame. Pairing by proximity, and reading a lone child as the wrapped case, covers both: four sections instead of one, fifteen cases instead of seven. --- .../tests/testcase_expectations.rs | 61 ++++++++++----- crates/devup-mcp-figma/src/section.rs | 44 +++++++---- crates/devup-mcp-figma/tests/section.rs | 76 +++++++++++++++++++ 3 files changed, 147 insertions(+), 34 deletions(-) diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs index e09eb96..19fcfec 100644 --- a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -15,6 +15,12 @@ //! ways, and normalising toward the note would have broken three goldens and //! moved away from the reference implementation. //! +//! The size a case states is the same kind of shorthand. Every note here ends +//! `boxSize="150px"`, we emit nothing, and reading that as a defect and +//! restoring the size broke thirty-eight goldens — among them the very cases +//! being read. A shape on a page carries the canvas it was drawn on, not a size +//! anyone chose, and the plugin drops it; the note writes down what was drawn. +//! //! So a difference here is a question: check the corpus before treating it as //! a defect. Where the corpus agrees with us the note is shorthand; where it //! agrees with the note, that is ours to fix. @@ -45,21 +51,25 @@ struct Case { } fn cases(snapshot: &Snapshot) -> Vec { - let node = |id: &str| snapshot.nodes.get(id); - let x_of = |id: &str| { - node(id) - .and_then(|n| n.typed_view().number("x")) - .unwrap_or(f64::MAX) + let centre = |id: &str| { + let view = snapshot.nodes.get(id)?.typed_view(); + let (x, y) = (view.number("x")?, view.number("y")?); + let (w, h) = ( + view.number("width").unwrap_or(0.0), + view.number("height").unwrap_or(0.0), + ); + Some((x + w / 2.0, y + h / 2.0)) }; - // A case frame holds the shape; the Code frame beside it holds the text. - let mut roots: Vec<&String> = snapshot.roots.iter().collect(); - roots.sort_by(|left, right| x_of(left).total_cmp(&x_of(right))); - - let mut expectations: Vec<(f64, String)> = Vec::new(); - let mut shapes: Vec<(f64, String)> = Vec::new(); - for root in roots { - let Some(raw) = node(root) else { continue }; + // A case frame holds the shape; a Code frame beside it holds the text that + // says what the shape should produce. + let mut expectations: Vec<((f64, f64), String)> = Vec::new(); + let mut shapes: Vec<((f64, f64), String)> = Vec::new(); + for root in &snapshot.roots { + let Some(raw) = snapshot.nodes.get(root) else { + continue; + }; + let Some(at) = centre(root) else { continue }; let view = raw.typed_view(); let text = view .child_ids() @@ -73,23 +83,34 @@ fn cases(snapshot: &Snapshot) -> Vec { .map(str::to_owned) }); match text { - Some(text) => expectations.push((x_of(root), normalise(&text))), + Some(text) => expectations.push((at, normalise(&text))), None => { - if let Some(shape) = view.child_ids().next() { - shapes.push((x_of(root), shape.to_owned())); - } + // A case is sometimes wrapped in a frame that only positions it + // and sometimes stands as the root itself, so neither the root + // nor its first child is right on its own. A lone child is the + // wrapped case; anything else is the case. + let children = view.child_ids().collect::>(); + let shape = match children.as_slice() { + [only] => (*only).to_owned(), + _ => root.clone(), + }; + shapes.push((at, shape)); } } } - // Each expectation belongs to the nearest shape to its right. + // Pair by proximity rather than by a fixed direction: a case sits above its + // note in one section and beside it in another, so any rule about which way + // to look holds for one layout and silently pairs nothing in the next. expectations .into_iter() .filter_map(|(at, expected)| { shapes .iter() - .filter(|(shape_at, _)| *shape_at >= at) - .min_by(|left, right| left.0.total_cmp(&right.0)) + .min_by(|left, right| { + let distance = |(x, y): (f64, f64)| (x - at.0).powi(2) + (y - at.1).powi(2); + distance(left.0).total_cmp(&distance(right.0)) + }) .map(|(_, node_id)| Case { expected, node_id: node_id.clone(), diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index a49bc77..cff213f 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -160,20 +160,36 @@ pub fn build_section_index( } } // A Section is answered with the screens inside it, because converting one - // whole is too much. A Section holding none — a catalogue of small cases, a - // page of components, anything not phone or desktop shaped — produced an - // empty list, and the caller was told to select from nothing with no way - // forward. Its own children are the honest answer there: they are what the - // Section actually offers. - if screen_nodes.is_empty() { - screen_nodes = section - .typed_view() - .child_ids() - .filter_map(|child_id| snapshot.nodes.get(child_id)) - .filter_map(|child| ExploreNode::try_from(child).ok()) - .filter(|child| child.visible) - .collect(); - } + // whole is too much. But a Section is an explicit grouping, and screen shape + // is a guess used to find screens on a page that has no grouping: applied + // here it silently drops whatever is not phone or desktop shaped. A section + // of small cases offered nothing at all, and — worse, because it looked + // like an answer — a section mixing tall notes with small cases offered the + // notes and hid every case. What the Section holds is what it offers, so its + // own children stand alongside the screens found within it. + let found_screen_ids = screen_nodes + .iter() + .map(|node| node.node_id.clone()) + .collect::>(); + let children = section + .typed_view() + .child_ids() + .filter_map(|child_id| snapshot.nodes.get(child_id)) + // Text lying directly on a Section is how designers label one, not + // something to convert. Text that is content sits inside a frame. + .filter(|child| child.node_type != "TEXT") + .filter_map(|child| ExploreNode::try_from(child).ok()) + .filter(|child| child.visible) + .filter(|child| !found_screen_ids.contains(&child.node_id)) + // A child holding a screen would offer that screen twice over, once + // whole and once inside itself. + .filter(|child| { + !found_screen_ids + .iter() + .any(|screen| is_descendant(snapshot, screen, &child.node_id)) + }) + .collect::>(); + screen_nodes.extend(children); let screen_ids = screen_nodes .iter() .map(|node| node.node_id.clone()) diff --git a/crates/devup-mcp-figma/tests/section.rs b/crates/devup-mcp-figma/tests/section.rs index c4b4a1a..d9624ef 100644 --- a/crates/devup-mcp-figma/tests/section.rs +++ b/crates/devup-mcp-figma/tests/section.rs @@ -48,6 +48,82 @@ fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Re Ok(()) } +#[test] +fn index_offers_small_cases_standing_beside_screen_shaped_notes() -> anyhow::Result<()> { + // Screen shape is a guess for finding screens on an ungrouped page. A + // Section of cases annotated with tall notes turns that guess upside down: + // the notes measure like screens and the cases do not, so the index offered + // every note and hid every case — an answer that looked complete. + let target = + FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=20-1")?; + let nodes = [ + node( + "20:1", + "SECTION", + json!({ + "name": "Gradient", "parentId": "0:1", "visible": true, + "childrenIds": ["20:2", "20:3", "20:4"], + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1600, "height": 1600} + }), + ), + node( + "20:2", + "FRAME", + json!({ + "name": "Code", "parentId": "20:1", "visible": true, "childrenIds": [], + "absoluteBoundingBox": {"x": 0, "y": 300, "width": 600, "height": 391} + }), + ), + node( + "20:3", + "FRAME", + json!({ + "name": "Case", "parentId": "20:1", "visible": true, "childrenIds": [], + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 150, "height": 150} + }), + ), + node( + "20:4", + "TEXT", + json!({ + "name": "Label", "parentId": "20:1", "visible": true, "childrenIds": [], + "characters": "Gradient", "absoluteBoundingBox": {"x": 0, "y": 700, "width": 90, "height": 24} + }), + ), + ] + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect::>(); + let snapshot = Snapshot { + file_key: "FileKey123".to_owned(), + version: Some("v1".to_owned()), + roots: vec!["20:1".to_owned()], + nodes, + diagnostics: Vec::new(), + }; + + let index = build_section_index(&snapshot, &target)?; + + let offered = index + .candidates + .iter() + .map(|candidate| candidate.node_id.as_str()) + .collect::>(); + assert!( + offered.contains(&"20:2"), + "the note still stands: {offered:?}" + ); + assert!( + offered.contains(&"20:3"), + "the case is what was asked for: {offered:?}" + ); + assert!( + !offered.contains(&"20:4"), + "text on a Section labels it: {offered:?}" + ); + Ok(()) +} + #[test] fn selection_and_batches_are_strict_bounded_and_deterministic() -> anyhow::Result<()> { let target = From 42ce1142f0e738011bed8de46bf523f523cea430 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 12:35:43 +0900 Subject: [PATCH 54/69] fix(figma): offer a Section's children where the choosing actually happens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Section index picks its candidates in the injected script, and the section node it returns carries only those picks as its children. So the fallback added on the Rust side read an already-filtered list and could never widen it: it changed the synthetic fixture and nothing a real file would produce. Both sides now hold the same rule, and the one that runs against Figma is the script. Screen shape is a guess for finding screens on a page that has no grouping. A Section is grouping, already explicit, and the guess applied there answers with whatever measures like a phone. A Section of cases annotated with tall notes turns it upside down: the notes pass and the cases do not, so the index offered the notes and hid every case — an answer that looked complete, which is worse than the empty list a Section of only cases used to give. Text is offered with the rest. Text lying on a Section is usually its label, and that reading was worth making until the file showed a whole section of cases that are bare text sitting straight on the Section, rendered above each note, with goldens converting exactly those nodes. A menu costs a glance when it offers one thing too many and costs the work when it withholds. --- .../src/scripts/section_index.js | 27 +++++++++++++--- crates/devup-mcp-figma/src/section.rs | 3 -- crates/devup-mcp-figma/tests/section.rs | 31 ++++++++++++------- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/crates/devup-mcp-figma/src/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index 5db0332..151c43f 100644 --- a/crates/devup-mcp-figma/src/scripts/section_index.js +++ b/crates/devup-mcp-figma/src/scripts/section_index.js @@ -24,6 +24,15 @@ function isScreen(node, box) { && aspect >= 0.25 && aspect <= 2.5; } +function contains(ancestor, node) { + let parent = node.parent; + while (parent) { + if (parent.id === ancestor.id) return true; + parent = parent.parent; + } + return false; +} + function breadcrumb(node) { const names = []; let current = node; @@ -89,12 +98,20 @@ for (let index = 0; index < queue.length && traversalCount < MAX_TRAVERSED_NODES } if ("children" in node) queue.push(...node.children); } -// Nothing screen shaped inside means the search found nothing to offer, and -// the caller is left selecting from an empty list. The Section's own children -// are what it actually holds, so carry them instead — a catalogue of small -// cases or a page of components has no screens by this measure. -if (candidateNodes.length === 0 && "children" in section) { +// Screen shape is a guess for finding screens on a page that has no grouping. +// A Section is grouping, already explicit, and the guess applied there answers +// with whatever happens to measure like a phone. A Section of small cases +// annotated with tall notes turns it upside down: the notes pass and the cases +// do not, so the index offered the notes and hid every case — an answer that +// looked complete, which is worse than the empty list a Section of cases used +// to give. What the Section holds is what it offers. +if ("children" in section) { + const chosen = new Set(candidateNodes.map(({ node }) => node.id)); for (const node of section.children) { + if (chosen.has(node.id)) continue; + // A child holding a screen would offer that screen twice over, once whole + // and once inside itself. + if (candidateNodes.some(({ node: screen }) => contains(node, screen))) continue; const box = bounds(node); if (box && node.visible !== false) candidateNodes.push({ node, box }); } diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index cff213f..55d396f 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -175,9 +175,6 @@ pub fn build_section_index( .typed_view() .child_ids() .filter_map(|child_id| snapshot.nodes.get(child_id)) - // Text lying directly on a Section is how designers label one, not - // something to convert. Text that is content sits inside a frame. - .filter(|child| child.node_type != "TEXT") .filter_map(|child| ExploreNode::try_from(child).ok()) .filter(|child| child.visible) .filter(|child| !found_screen_ids.contains(&child.node_id)) diff --git a/crates/devup-mcp-figma/tests/section.rs b/crates/devup-mcp-figma/tests/section.rs index d9624ef..56c67f4 100644 --- a/crates/devup-mcp-figma/tests/section.rs +++ b/crates/devup-mcp-figma/tests/section.rs @@ -7,7 +7,7 @@ use devup_mcp_figma::{ use serde_json::{Map, json}; #[test] -fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Result<()> { +fn index_contains_top_level_visible_children_in_visual_order() -> anyhow::Result<()> { let target = FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=10-1")?; let snapshot = fixture_snapshot(); @@ -15,14 +15,19 @@ fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Re let index: SectionIndex = build_section_index(&snapshot, &target)?; assert_eq!(index.section.node_id, "10:1"); - assert_eq!(index.candidates.len(), 3); + // The note at 10:5 is offered too. This index is a menu to choose from, and + // deciding for the caller which children are worth showing means being + // wrong in the one direction that cannot be seen: an offer too many costs a + // glance, an offer withheld hides the work entirely. Bare text is a case in + // its own right — a whole section of this file is nothing else. + assert_eq!(index.candidates.len(), 4); assert_eq!( index .candidates .iter() .map(|candidate| candidate.node_id.as_str()) .collect::>(), - ["10:3", "10:2", "10:4"] + ["10:3", "10:2", "10:5", "10:4"] ); let first = &index.candidates[0]; assert_eq!(first.name, "First"); @@ -39,11 +44,12 @@ fn index_contains_only_top_level_visible_screens_in_visual_order() -> anyhow::Re .contains(&"inside-section".to_owned()) ); assert!(first.canonical_url.ends_with("node-id=10-3")); + // Hidden, and nested inside a screen already offered. assert!( !index .candidates .iter() - .any(|candidate| { matches!(candidate.node_id.as_str(), "10:5" | "10:6" | "10:7") }) + .any(|candidate| { matches!(candidate.node_id.as_str(), "10:6" | "10:7") }) ); Ok(()) } @@ -117,10 +123,10 @@ fn index_offers_small_cases_standing_beside_screen_shaped_notes() -> anyhow::Res offered.contains(&"20:3"), "the case is what was asked for: {offered:?}" ); - assert!( - !offered.contains(&"20:4"), - "text on a Section labels it: {offered:?}" - ); + // Text on a Section is often its label, but a whole section of this file is + // cases that are themselves bare text sitting straight on the Section, and + // the corpus converts them. Reading text as decoration hid every one. + assert!(offered.contains(&"20:4"), "text can be a case: {offered:?}"); Ok(()) } @@ -130,7 +136,10 @@ fn selection_and_batches_are_strict_bounded_and_deterministic() -> anyhow::Resul FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=10-1")?; let index = build_section_index(&fixture_snapshot(), &target)?; - assert_eq!(index.select(&[], true)?, vec!["10:3", "10:2", "10:4"]); + assert_eq!( + index.select(&[], true)?, + vec!["10:3", "10:2", "10:5", "10:4"] + ); assert_eq!( index.select(&["10:4".to_owned(), "10:3".to_owned()], false)?, vec!["10:3", "10:4"] @@ -155,8 +164,8 @@ fn selection_and_batches_are_strict_bounded_and_deterministic() -> anyhow::Resul }, )?; assert_eq!(batches.len(), 2); - assert_eq!(batches[0].root_ids, ["10:3", "10:2"]); - assert_eq!(batches[1].root_ids, ["10:4"]); + assert_eq!(batches[0].root_ids, ["10:3", "10:5"]); + assert_eq!(batches[1].root_ids, ["10:2", "10:4"]); assert!(!batches.iter().any(|batch| batch.oversized)); let oversized = plan_batches( From 074142e8cd9e917090fda84bfc51db608ada7c49 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 12:43:08 +0900 Subject: [PATCH 55/69] test(devup-ui): give each stated case one note and one only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letting every note take whatever case lies nearest let them crowd onto the same one — three notes in the Grid section all read against its first card, two of those comparisons meaningless. A note whose case sits far away did worse and claimed the commentary beside it, so the effect section reported a difference against a paragraph of Korean prose explaining Safari's backdrop-filter. Closest pairs are settled first and each side is spoken for once. Six sections now read cleanly, and the sections captured since — effect and outline-border — say the same as the ones before them: every difference is the note written the way a person would write it, and the corpus holds what we emit. `0px 4px 4px rgba(0, 0, 0, 0.25)` against our `0 4px 4px 0 #00000040`, `3px solid` against `solid 3px`, and the size a shape is drawn at, which the plugin has never stated. --- .../tests/testcase_expectations.rs | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs index 19fcfec..20aeb9a 100644 --- a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -102,21 +102,41 @@ fn cases(snapshot: &Snapshot) -> Vec { // Pair by proximity rather than by a fixed direction: a case sits above its // note in one section and beside it in another, so any rule about which way // to look holds for one layout and silently pairs nothing in the next. - expectations - .into_iter() - .filter_map(|(at, expected)| { - shapes - .iter() - .min_by(|left, right| { - let distance = |(x, y): (f64, f64)| (x - at.0).powi(2) + (y - at.1).powi(2); - distance(left.0).total_cmp(&distance(right.0)) - }) - .map(|(_, node_id)| Case { - expected, - node_id: node_id.clone(), - }) - }) - .collect() + // + // One note, one case. Letting each note take whatever is nearest lets them + // crowd onto the same case, and a note whose case is far away claims the + // commentary lying beside it instead — a difference reported against a + // paragraph of Korean prose. Closest pairs are settled first, and each side + // is spoken for once. + let mut pairs = Vec::with_capacity(expectations.len() * shapes.len()); + for (note, (at, _)) in expectations.iter().enumerate() { + for (case, (case_at, _)) in shapes.iter().enumerate() { + let distance = (case_at.0 - at.0).powi(2) + (case_at.1 - at.1).powi(2); + pairs.push((distance, note, case)); + } + } + pairs.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.cmp(&right.1)) + .then_with(|| left.2.cmp(&right.2)) + }); + + let mut spoken_for_note = vec![false; expectations.len()]; + let mut spoken_for_case = vec![false; shapes.len()]; + let mut cases = Vec::new(); + for (_, note, case) in pairs { + if spoken_for_note[note] || spoken_for_case[case] { + continue; + } + spoken_for_note[note] = true; + spoken_for_case[case] = true; + cases.push(Case { + expected: expectations[note].1.clone(), + node_id: shapes[case].1.clone(), + }); + } + cases } #[test] From 491dad17ef7e8c4c304cc834fe0c2941862aac49 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 13:49:17 +0900 Subject: [PATCH 56/69] test(devup-ui): let the note say which node it is describing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A case is sometimes wrapped in a frame that only positions it and sometimes is that frame, and nothing about the frame says which. Choosing by shape — a lone child means a wrapper — held for the gradient swatches and broke on the clamp frames, which hold one text each and are the case. Every clamp comparison then read a bare Text against a note describing a Flex around it. The note settles it. One that opens a container and puts something inside is describing the frame; a single element is describing what the frame holds. Both readings are kept and the note picks between them. The report also names the node now, which is what let the last difference be explained rather than guessed at: the note beside the clamp cases had paired with the decoy frame the design keeps nearby to show the difference, and the clamp behaviour it appeared to contradict turned out to be exact. Reading the three text nodes directly — maxLines 1 filling, maxLines 2 filling, maxLines 1 hugging — the generated code matches what the section states for each, hug suppressing the truncation just as the note beside it asks. Eight sections and thirty-one cases now, and the answer has not changed: the corpus holds what we emit, down to `` verbatim where the note leaves the alignment out. --- .../tests/testcase_expectations.rs | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs index 20aeb9a..1329cf5 100644 --- a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -64,7 +64,7 @@ fn cases(snapshot: &Snapshot) -> Vec { // A case frame holds the shape; a Code frame beside it holds the text that // says what the shape should produce. let mut expectations: Vec<((f64, f64), String)> = Vec::new(); - let mut shapes: Vec<((f64, f64), String)> = Vec::new(); + let mut shapes: Vec<((f64, f64), String, Option)> = Vec::new(); for root in &snapshot.roots { let Some(raw) = snapshot.nodes.get(root) else { continue; @@ -86,15 +86,16 @@ fn cases(snapshot: &Snapshot) -> Vec { Some(text) => expectations.push((at, normalise(&text))), None => { // A case is sometimes wrapped in a frame that only positions it - // and sometimes stands as the root itself, so neither the root - // nor its first child is right on its own. A lone child is the - // wrapped case; anything else is the case. - let children = view.child_ids().collect::>(); - let shape = match children.as_slice() { - [only] => (*only).to_owned(), - _ => root.clone(), + // and sometimes stands as the root itself, and nothing about the + // frame says which. The note does: one that opens a container + // and puts something inside is describing the frame, one that is + // a single element is describing what the frame holds. So keep + // both readings and let the note pick. + let lone_child = match view.child_ids().collect::>().as_slice() { + [only] => Some((*only).to_owned()), + _ => None, }; - shapes.push((at, shape)); + shapes.push((at, root.clone(), lone_child)); } } } @@ -110,7 +111,7 @@ fn cases(snapshot: &Snapshot) -> Vec { // is spoken for once. let mut pairs = Vec::with_capacity(expectations.len() * shapes.len()); for (note, (at, _)) in expectations.iter().enumerate() { - for (case, (case_at, _)) in shapes.iter().enumerate() { + for (case, (case_at, _, _)) in shapes.iter().enumerate() { let distance = (case_at.0 - at.0).powi(2) + (case_at.1 - at.1).powi(2); pairs.push((distance, note, case)); } @@ -131,10 +132,17 @@ fn cases(snapshot: &Snapshot) -> Vec { } spoken_for_note[note] = true; spoken_for_case[case] = true; - cases.push(Case { - expected: expectations[note].1.clone(), - node_id: shapes[case].1.clone(), - }); + let expected = expectations[note].1.clone(); + // Three angle brackets means an element opened, something placed inside + // it, and the element closed — a container. One or two is a single + // element, with or without text of its own. + let describes_a_container = expected.matches('<').count() >= 3; + let (_, root, lone_child) = &shapes[case]; + let node_id = match lone_child { + Some(child) if !describes_a_container => child.clone(), + _ => root.clone(), + }; + cases.push(Case { expected, node_id }); } cases } @@ -148,7 +156,7 @@ fn generated_code_matches_what_each_case_states() { }; let mut agreed = 0usize; - let mut differed: BTreeMap> = BTreeMap::new(); + let mut differed: BTreeMap> = BTreeMap::new(); for entry in entries.flatten() { let path = entry.path(); let name = path.file_name().and_then(|v| v.to_str()).unwrap_or(""); @@ -174,10 +182,11 @@ fn generated_code_matches_what_each_case_states() { if actual == case.expected { agreed += 1; } else { - differed - .entry(label.clone()) - .or_default() - .push((case.expected, actual)); + differed.entry(label.clone()).or_default().push(( + case.node_id.clone(), + case.expected, + actual, + )); } } } @@ -190,7 +199,8 @@ fn generated_code_matches_what_each_case_states() { eprintln!("cases: {total}, matching what the design states: {agreed}"); for (label, entries) in &differed { eprintln!("\n=== {label}"); - for (expected, actual) in entries { + for (node_id, expected, actual) in entries { + eprintln!(" node : {node_id}"); eprintln!(" states : {expected}"); eprintln!(" we emit: {actual}\n"); } From 75fdab381a39d1a7b9843ec8818abe18a64a29fb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 13:59:10 +0900 Subject: [PATCH 57/69] fix(mcp): stop offering a connection that cannot carry a collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Figma desktop app's local Dev Mode MCP was named as a third path beside direct OAuth and the host handoff. Every needs_figma handoff probed for it and reported it, doctor listed it, the catalog-rejected error offered it, and when the port answered the hint said its tools could be used directly without OAuth. It cannot serve devup-mcp at all. It exposes six read tools and use_figma is not among them, and use_figma is what every collection runs on — snapshot, explore, section index, theme all go through it. Its tools take a node id and no file key, addressing whatever the desktop app happens to have open. So the one path advertised as needing no OAuth is the one that cannot complete a single request, and an agent told the endpoint was responding spent its turn finding that out. Silence would have been better than that hint; the hint was worse than nothing because it was confident. Gone with it: the loopback probe doctor ran on every diagnosis, which is now free of network calls entirely. A test holds the contract by rendering both the doctor report and a host-policy handoff and requiring that neither mentions it. --- crates/devup-mcp-figma/src/source.rs | 1 - crates/devup-mcp-figma/tests/oauth_flow.rs | 5 +- crates/devup-mcp/src/server/diagnostics.rs | 106 ++++----------------- crates/devup-mcp/src/server/tools.rs | 2 +- crates/devup-mcp/tests/figma_doctor.rs | 61 +++++++++--- 5 files changed, 68 insertions(+), 107 deletions(-) diff --git a/crates/devup-mcp-figma/src/source.rs b/crates/devup-mcp-figma/src/source.rs index 0a930ab..3aa3c0c 100644 --- a/crates/devup-mcp-figma/src/source.rs +++ b/crates/devup-mcp-figma/src/source.rs @@ -167,7 +167,6 @@ impl UpstreamFailureKind { details["options"] = json!([ "Register devup-mcp on the Figma MCP Catalog waitlist: https://www.figma.com/mcp-catalog/", "Inject client credentials you obtained yourself via devup_figma_auth { action: \"configure\", clientId, clientSecret }", - "Use the local Dev Mode MCP in the Figma desktop app (no OAuth needed)", "Hand off to the official Figma MCP registered on the host (sourcePolicy: auto or host, the current default fallback)" ]); } diff --git a/crates/devup-mcp-figma/tests/oauth_flow.rs b/crates/devup-mcp-figma/tests/oauth_flow.rs index c7b9209..017dcdf 100644 --- a/crates/devup-mcp-figma/tests/oauth_flow.rs +++ b/crates/devup-mcp-figma/tests/oauth_flow.rs @@ -347,7 +347,10 @@ async fn dcr_403_is_classified_as_catalog_rejected_with_actionable_options() -> let options = error.details["options"] .as_array() .expect("catalog-rejected errors carry actionable options"); - assert_eq!(options.len(), 4); + // Three, not four: the local Dev Mode MCP was offered here and cannot + // serve devup-mcp at all, since it has no use_figma to run a collection + // with. An option that cannot work costs a turn to discover. + assert_eq!(options.len(), 3); assert!( options .iter() diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 018f240..94ae365 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -13,8 +13,8 @@ //! and tells the agent exactly which tool to call, what not to touch, and //! to stop and report rather than guess when no Figma MCP is reachable. //! - [`doctor_report`] backs the `devup_figma_auth {"action":"doctor"}` -//! action and reports which of the three connection paths (direct OAuth, -//! local Dev Mode MCP, host handoff) are actually usable right now, plus +//! action and reports which of the two connection paths (direct OAuth, +//! host handoff) are actually usable right now, plus //! client-specific setup data for the constraints that were verified by //! hand (client_name allowlist, redirect_uri shape, the silent callback //! port collision, PAT rejection). @@ -22,59 +22,22 @@ //! All facts embedded here (allowlist behavior, redirect_uri constraints, //! the callback-port trap) were measured against the real Figma Remote MCP //! registration endpoint; see `README.md`'s "Figma 연결 설정" section for -//! the same data in prose form. `doctor_report` performs exactly one -//! network-free-adjacent probe (a bounded local TCP connect) and no -//! external HTTP calls, so it stays cheap enough to call on every -//! diagnosis. - -use std::time::Duration; +//! the same data in prose form. `doctor_report` makes no network call at +//! all, so it stays cheap enough to call on every diagnosis. +//! +//! The Figma desktop app's local Dev Mode MCP was reported here as a third +//! path, probed for and described as usable without OAuth. It is not one: +//! it serves six read tools and `use_figma` is not among them, so every +//! collection devup-mcp performs — snapshot, explore, section index, theme — +//! has no tool to run. Its tools also take only a node id, addressing +//! whatever the desktop app currently has open rather than a file key. +//! Naming it as a path sent agents to a dead end, so it is named nowhere. use devup_mcp_figma::{ AuthStatus, ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, }; use serde_json::{Value, json}; -/// Loopback address the Figma desktop app's local Dev Mode MCP server binds -/// when enabled. OAuth-free; reachable regardless of which MCP client host -/// is in use. -pub const LOCAL_DEV_MODE_ADDR: &str = "127.0.0.1:3845"; -/// The MCP endpoint URL for the local Dev Mode server (same host/port as -/// [`LOCAL_DEV_MODE_ADDR`], with the `/mcp` path Figma serves it on). -pub const LOCAL_DEV_MODE_ENDPOINT: &str = "http://127.0.0.1:3845/mcp"; - -/// Upper bound on how long a local reachability probe may block a tool -/// call. Deliberately short: this is a same-host TCP connect, not a network -/// round trip, so anything slower than a few hundred milliseconds means the -/// port simply is not listening. -const PROBE_TIMEOUT: Duration = Duration::from_millis(300); - -/// Best-effort, error-swallowing TCP reachability probe. A refused -/// connection, a timeout, or any other I/O failure is reported as `false` -/// rather than propagated: a diagnostic probe must never fail the request -/// it is trying to help diagnose. -async fn probe_reachable(addr: &str, timeout: Duration) -> bool { - tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr)) - .await - .is_ok_and(|connection| connection.is_ok()) -} - -/// Probes [`LOCAL_DEV_MODE_ADDR`] with a short timeout. Never errors. -pub async fn local_dev_mode_reachable() -> bool { - probe_reachable(LOCAL_DEV_MODE_ADDR, PROBE_TIMEOUT).await -} - -fn local_dev_mode_hint(reachable: bool) -> String { - if reachable { - format!( - "{LOCAL_DEV_MODE_ENDPOINT} is responding. If the host has this local Dev Mode MCP registered, you can use its tools directly without OAuth." - ) - } else { - format!( - "{LOCAL_DEV_MODE_ENDPOINT} is not responding. Enable Figma desktop app -> Preferences -> Dev Mode MCP server to use it without OAuth (requires a paid plan with a Dev or Full seat)." - ) - } -} - /// Builds the `hostRequirement` block attached to every `needs_figma` /// handoff step. This is the single most important payload in this module: /// without it, an agent has to infer from a bare `calls` array that it must @@ -83,11 +46,9 @@ fn local_dev_mode_hint(reachable: bool) -> String { /// instead of stopping is unacceptable. `ifUnavailable.action` is always /// the literal string `"stop-and-report"`; do not remove or soften it. /// -/// Performs exactly one bounded local TCP probe -/// ([`local_dev_mode_reachable`]); never makes an external network call and -/// never fails the handoff it is attached to. +/// Makes no network call of any kind and never fails the handoff it is +/// attached to. pub async fn host_requirement() -> Value { - let reachable = local_dev_mode_reachable().await; json!({ "reason": "devup-mcp does not connect to Figma directly. The official Figma MCP registered on the host must run this read-only call on its behalf.", "steps": [ @@ -96,11 +57,6 @@ pub async fn host_requirement() -> Value { "Pass the raw result through unchanged to devup_figma_continue { sessionId, callId, result }.", "While status is needs_figma, repeat until expiresAt." ], - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "reachable": reachable, - "hint": local_dev_mode_hint(reachable) - }, "ifUnavailable": { "action": "stop-and-report", "message": "If no Figma MCP is reachable, stop immediately and report. Do not implement by guessing design values.", @@ -136,7 +92,6 @@ pub async fn host_requirement() -> Value { /// the stored token is fresh, and — when a fixed callback port is /// configured — whether it is actually free right now. pub async fn doctor_report(status: AuthStatus, direct: DirectPathSnapshot) -> Value { - let reachable = local_dev_mode_reachable().await; let direct_available = status == AuthStatus::Connected; json!({ "status": status, @@ -156,11 +111,6 @@ pub async fn doctor_report(status: AuthStatus, direct: DirectPathSnapshot) -> Va }, "reason": direct_reason(direct_available, direct.credential_source) }, - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "reachable": reachable, - "hint": "Figma desktop -> Preferences -> enable the Dev Mode MCP server (requires a Dev/Full seat)" - }, "hostHandoff": { "expectedTool": "use_figma", "note": "Cannot be verified from inside devup-mcp. The host must expose the official Figma MCP." @@ -189,8 +139,8 @@ fn direct_reason( default allowlisted client_name (see registrationClientName). If that returns 403, \ the allowlist rejected the name — register a client credential you obtained yourself \ via devup_figma_auth { action: \"configure\", clientId, clientSecret }, join the \ - Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/), use the local Dev \ - Mode MCP, or use the host handoff (sourcePolicy: auto or host)." + Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/), or use the host \ + handoff (sourcePolicy: auto or host)." } ClientCredentialSource::CliArg | ClientCredentialSource::Env @@ -241,10 +191,6 @@ fn client_setup() -> Value { } } } - }, - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "hint": "No OAuth needed. Turning on the Dev Mode MCP server in the Figma desktop app behaves identically from any MCP client. Requires a paid plan with a Dev or Full seat." } }) } @@ -253,21 +199,6 @@ fn client_setup() -> Value { mod tests { use super::*; - #[tokio::test] - async fn reports_reachable_when_a_listener_is_bound() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap().to_string(); - assert!(probe_reachable(&addr, PROBE_TIMEOUT).await); - } - - #[tokio::test] - async fn reports_unreachable_without_erroring_when_the_port_is_closed() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap().to_string(); - drop(listener); - assert!(!probe_reachable(&addr, PROBE_TIMEOUT).await); - } - #[tokio::test] async fn host_requirement_always_instructs_stop_and_report_when_unavailable() { let value = host_requirement().await; @@ -279,7 +210,6 @@ mod tests { .is_empty() ); assert!(value["steps"].as_array().unwrap().len() >= 4); - assert!(value["localDevMode"]["reachable"].is_boolean()); } #[tokio::test] @@ -347,10 +277,6 @@ mod tests { let disconnected = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; assert_eq!(disconnected["status"], "disconnected"); assert_eq!(disconnected["paths"]["direct"]["available"], false); - assert_eq!( - disconnected["paths"]["localDevMode"]["endpoint"], - LOCAL_DEV_MODE_ENDPOINT - ); assert_eq!( disconnected["paths"]["hostHandoff"]["expectedTool"], "use_figma" diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 7da0be5..bc97629 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; /// `action` is `status`, `login`, `logout`, `configure`, or `doctor`. /// `doctor` never touches OAuth state; it measures which connection paths -/// (direct OAuth, local Dev Mode MCP, host handoff) are currently usable +/// (direct OAuth, host handoff) are currently usable /// and returns client-specific setup guidance. `configure` persists a /// pre-registered client credential (`clientId`, optional `clientSecret`) /// so later `login` calls skip Dynamic Client Registration entirely; the diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index 911ed14..5e6ce0f 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -139,11 +139,6 @@ async fn doctor_action_reports_measured_paths_and_client_setup_data() -> anyhow: assert_eq!(output["status"], "disconnected"); assert_eq!(output["paths"]["direct"]["available"], false); assert!(output["paths"]["direct"]["reason"].is_string()); - assert_eq!( - output["paths"]["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" - ); - assert!(output["paths"]["localDevMode"]["reachable"].is_boolean()); assert_eq!(output["paths"]["hostHandoff"]["expectedTool"], "use_figma"); let client_setup = &output["clientSetup"]; @@ -173,10 +168,6 @@ async fn doctor_action_reports_measured_paths_and_client_setup_data() -> anyhow: .unwrap() .contains("figma") ); - assert_eq!( - client_setup["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" - ); // No actual credential material, ever. `clientSetup` legitimately // documents *where* clientId/clientSecret/PAT go (field names and a @@ -262,11 +253,6 @@ async fn needs_figma_always_carries_an_actionable_host_requirement() -> anyhow:: .unwrap() .contains("doctor") ); - assert!(host_requirement["localDevMode"]["reachable"].is_boolean()); - assert_eq!( - host_requirement["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" - ); Ok(()) } @@ -518,3 +504,50 @@ async fn configure_action_fails_for_auth_backends_that_do_not_support_it() -> an assert!(!error.to_string().is_empty()); Ok(()) } + +/// The Figma desktop app's local Dev Mode MCP serves six read tools and +/// `use_figma` is not among them, so every collection devup-mcp performs — +/// snapshot, explore, section index, theme — has no tool there to run. Its +/// tools also address whatever the desktop app currently has open rather than +/// a file key. It was reported as a third connection path and described as +/// usable without OAuth, and an agent that believed it spent its turn finding +/// out otherwise. Nothing devup-mcp says should name it. +#[tokio::test] +async fn nothing_offers_the_local_dev_mode_server_as_a_path() -> anyhow::Result<()> { + let doctor = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", + json!({ "action": "doctor" }), + ) + .await? + .structured_content + .unwrap(); + let handoff = call_named_tool( + Arc::new(AuthProbe { + status: AuthStatus::Disconnected, + }), + Arc::new(UnavailableUpstream::default()), + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "sourcePolicy": "host" + }), + ) + .await? + .structured_content + .unwrap(); + + for (label, value) in [("doctor", &doctor), ("export handoff", &handoff)] { + let rendered = serde_json::to_string(value)?; + for forbidden in ["localDevMode", "3845", "Dev Mode"] { + assert!( + !rendered.contains(forbidden), + "{label} still names the local Dev Mode server via {forbidden:?}" + ); + } + } + Ok(()) +} From 66cedc548129d3536b1e802c86d33980a6e68793 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 15:19:49 +0900 Subject: [PATCH 58/69] docs: drop the connection path that cannot carry a collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local Dev Mode MCP was documented as the second of three ways devup-mcp reaches Figma, described as needing no OAuth and behaving the same from any client. It cannot serve devup-mcp at all: it exposes six read tools, use_figma is not among them, and every collection — snapshot, explore, section index, theme — runs a script through use_figma. Its tools take a node id and no file key, addressing whatever the desktop app happens to have open. Two paths now, not three, and the doctor sample matches what doctor returns since the probe it reported is gone. What the local server is, and why it is not a path, stays written down where the third entry used to be, so the next reader does not rediscover it by spending a turn on it. --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 49b7c34..fad4608 100644 --- a/README.md +++ b/README.md @@ -90,14 +90,13 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. "callbackPort": { "port": null, "free": null }, "reason": "저장된 자격증명 없음. ..." }, - "localDevMode": { "endpoint": "http://127.0.0.1:3845/mcp", "reachable": false, "hint": "..." }, "hostHandoff": { "expectedTool": "use_figma", "note": "..." } }, - "clientSetup": { "constraints": { ... }, "opencode": { ... }, "claudeCode": "...", "codex": "...", "localDevMode": { ... } } + "clientSetup": { "constraints": { ... }, "opencode": { ... }, "claudeCode": "...", "codex": "..." } } ``` -`paths.localDevMode.reachable`은 `127.0.0.1:3845`에 대한 300ms 이내 로컬 TCP 연결 확인 결과이며 실패해도 오류를 던지지 않습니다. `needs_figma` 응답에도 같은 프로브 결과가 `hostRequirement.localDevMode`로 포함됩니다. `paths.direct.credentialSource`는 `cli-arg`, `env`, `credential-store`, `none` 중 하나이고, `tokenState`는 `valid`, `expired`, `absent` 중 하나이며, `callbackPort`는 `--figma-callback-port`를 지정했을 때만 실측한 `port`/`free`를 담습니다. 자세한 제약과 3가지 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. +`doctor`는 네트워크 호출을 전혀 하지 않습니다. `paths.direct.credentialSource`는 `cli-arg`, `env`, `credential-store`, `none` 중 하나이고, `tokenState`는 `valid`, `expired`, `absent` 중 하나이며, `callbackPort`는 `--figma-callback-port`를 지정했을 때만 실측한 `port`/`free`를 담습니다. 자세한 제약과 두 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. ### direct 경로에 사전 등록된 client 자격증명 주입하기 @@ -111,13 +110,14 @@ Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급 ## Figma 연결 설정 -devup-mcp가 Figma에 붙는 경로는 세 가지입니다. +devup-mcp가 Figma에 붙는 경로는 두 가지입니다. 1. **원격 OAuth (`direct`)** — `devup_figma_auth { action: "login" }`으로 브라우저 인증. Figma MCP Catalog에 승인된 client만 등록할 수 있습니다. -2. **로컬 Dev Mode MCP (`http://127.0.0.1:3845/mcp`)** — Figma 데스크톱 앱의 Dev Mode MCP 서버. OAuth가 필요 없고 어떤 MCP 클라이언트에서도 동일하게 동작하지만, Figma 데스크톱 앱에서 켜야 하고 Dev/Full 시트가 있는 유료 플랜이 필요합니다. -3. **호스트 핸드오프 (`host`)** — devup-mcp가 직접 Figma에 붙지 않고, 호스트에 이미 등록된 공식 Figma MCP가 `needs_figma` 응답의 `calls`를 대신 실행하도록 위임합니다. `auto` 정책의 기본 fallback 경로입니다. +2. **호스트 핸드오프 (`host`)** — devup-mcp가 직접 Figma에 붙지 않고, 호스트에 이미 등록된 공식 Figma MCP가 `needs_figma` 응답의 `calls`를 대신 실행하도록 위임합니다. `auto` 정책의 기본 fallback 경로입니다. -세 경로 중 무엇이 지금 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요. +두 경로 중 무엇이 지금 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요. + +Figma 데스크톱 앱의 로컬 Dev Mode MCP(`http://127.0.0.1:3845/mcp`)는 세 번째 경로로 안내했으나 제거했습니다. 읽기 도구 6개(`get_design_context`, `get_variable_defs`, `get_screenshot`, `get_motion_context`, `get_metadata`, `get_figjam`)만 제공하고 그중에 `use_figma`가 없습니다. devup-mcp의 수집은 snapshot·explore·section index·theme 모두 `use_figma`로 스크립트를 실행하므로 로컬에서는 실행할 도구 자체가 없습니다. 도구들이 `fileKey`를 받지 않고 데스크톱 앱에 열려 있는 파일만 가리키는 것도 같은 이유로 맞지 않습니다. "OAuth 없이 바로 쓸 수 있다"는 안내는 확신에 차서 틀린 안내였고, 믿은 쪽이 한 턴을 버린 뒤에야 알게 됩니다. ### 원격 OAuth 등록 제약 (실측) From db1b795375380fcfac6834ae8a2cc01c042973ac Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 15:39:41 +0900 Subject: [PATCH 59/69] fix(mcp): wait out a spent allowance instead of losing the collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collection is a burst. A Section spends five to seventeen calls back to back and Figma meters by the minute, so a large enough target crosses its own limit partway through its own work. The refusal ended the collection there: every call already spent was discarded and nothing came back, which is the worst of both outcomes — the allowance is gone and there is no result to show for it. Collecting one section repeatedly cost the allowance and returned nothing, however long the wait between attempts. The refusal asks to be waited out. It is marked retryable and Figma names the seconds in Retry-After, which the relay does not forward today, so the wait is usually a widening guess instead — and a guess of twenty seconds is enough, because what was crossed is a per-minute line that rolls over on its own. Bounded at three attempts, because an allowance that is genuinely gone must still be reported rather than waited on forever. Auto still does not answer a spent allowance by handing the work to the host: the source a caller asked for is the source that reports. --- crates/devup-mcp/Cargo.toml | 3 + crates/devup-mcp/src/server/mod.rs | 47 +++++- crates/devup-mcp/tests/rate_limit_patience.rs | 151 ++++++++++++++++++ .../devup-mcp/tests/source_orchestration.rs | 9 +- 4 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 crates/devup-mcp/tests/rate_limit_patience.rs diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index 4e15ef4..f3aa203 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -31,3 +31,6 @@ axum.workspace = true # UNC path on Windows and never match. dunce.workspace = true reqwest.workspace = true +# `start_paused` lets a test watch the retry waits elapse without spending the +# minute they describe. +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index f30d21d..97935e6 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -33,9 +33,9 @@ use devup_mcp_figma::{ CollectionRequest, CollectionScope, CollectorSession, CollectorStep, CredentialStore, DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, - KeyringCredentialStore, OAuthManager, RemoteFigmaClient, ResourceScope, SearchReadOptions, - SecretString, SectionCandidate, SectionIndex, SectionReadOptions, SourcePolicy, SystemBrowser, - TokenState, fallback_allowed_for_error, + KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, + SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, + SourcePolicy, SystemBrowser, TokenState, UpstreamResult, fallback_allowed_for_error, }; use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; @@ -287,13 +287,52 @@ impl DevupServer { } } + /// A collection is a burst: a Section of any size spends five to seventeen + /// calls back to back, and Figma meters by the minute. So a large enough + /// target outruns its own allowance partway through, and the refusal used + /// to end the whole collection — discarding every call already spent and + /// returning nothing, which is the worst of both: the allowance is gone and + /// there is no result to show for it. Waiting is what the refusal asks for. + /// It is marked retryable and often carries the exact number of seconds. + /// + /// Bounded, because an allowance that is genuinely exhausted must still be + /// reported rather than waited on forever: three attempts, each waiting + /// what upstream asked for, or a widening guess when it did not say. + async fn call_waiting_out_a_spent_allowance( + &self, + call: ReadToolCall, + ) -> Result { + const ATTEMPTS: u32 = 3; + const LONGEST_WAIT: u64 = 90; + + let mut attempt = 1; + loop { + let error = match self.services.upstream.call_read_tool(call.clone()).await { + Ok(result) => return Ok(result), + Err(error) => error, + }; + if error.code != ErrorCode::DevupFigmaRateLimited || attempt >= ATTEMPTS { + return Err(error); + } + let asked_for = error + .details + .get("retryAfterSeconds") + .and_then(serde_json::Value::as_u64); + let wait = asked_for + .unwrap_or(u64::from(attempt) * 20) + .min(LONGEST_WAIT); + tokio::time::sleep(std::time::Duration::from_secs(wait)).await; + attempt += 1; + } + } + async fn run_direct(&self, request: CollectionRequest) -> Result { let mut collector = CollectorSession::new(request); loop { match collector.advance()? { CollectorStep::Call(planned) => { let call_id = planned.id.clone(); - match self.services.upstream.call_read_tool(planned.call).await { + match self.call_waiting_out_a_spent_allowance(planned.call).await { // A Section target is not a failed call — the script // throws, and MCP delivers that as a successful result // carrying `isError`. Handing it to `accept` made the diff --git a/crates/devup-mcp/tests/rate_limit_patience.rs b/crates/devup-mcp/tests/rate_limit_patience.rs new file mode 100644 index 0000000..b7fd533 --- /dev/null +++ b/crates/devup-mcp/tests/rate_limit_patience.rs @@ -0,0 +1,151 @@ +//! A collection is a burst: a Section spends five to seventeen calls back to +//! back and Figma meters by the minute, so a large enough target outruns its +//! own allowance partway through. That refusal used to end the collection and +//! return nothing, spending the allowance for no result at all. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{ + AuthStatus, DevupError, ErrorCode, FigmaUpstream, ReadToolCall, UpstreamResult, +}; +use rmcp::{ + ServiceExt, + model::{CallToolRequestParams, CallToolResult}, +}; +use serde_json::{Map, Value, json}; + +struct ConnectedAuth; + +#[async_trait] +impl DevupAuth for ConnectedAuth { + async fn status(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +/// Refuses the first `refusals` calls the way a spent allowance does, then +/// answers. Counts every attempt so the test can tell a retry from a give-up. +struct SpentAllowance { + refusals: AtomicUsize, + attempts: AtomicUsize, + retry_after_seconds: Option, +} + +#[async_trait] +impl FigmaUpstream for SpentAllowance { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["get_metadata".to_owned(), "use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.attempts.fetch_add(1, Ordering::SeqCst); + if self + .refusals + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(1) + }) + .is_ok() + { + let mut details = json!({ "source": "direct" }); + if let Some(seconds) = self.retry_after_seconds { + details["retryAfterSeconds"] = json!(seconds); + } + return Err(DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + "Figma request rate limit reached.", + true, + details, + )); + } + Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "answered — the collection got past the allowance", + false, + )) + } +} + +async fn export(upstream: Arc) -> anyhow::Result { + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + let (server_transport, client_transport) = tokio::io::duplex(64 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "sourcePolicy": "direct" + }) + .as_object() + .cloned() + .unwrap(); + let result = client + .call_tool( + CallToolRequestParams::new("devup_figma_export".to_owned()).with_arguments(arguments), + ) + .await?; + client.cancel().await?; + task.await??; + Ok(result) +} + +/// The refusal asks to be waited out — it is marked retryable and often names +/// the seconds. Honouring that turns a lost collection into a slow one. +#[tokio::test(start_paused = true)] +async fn a_refused_call_is_waited_out_rather_than_ending_the_collection() -> anyhow::Result<()> { + let upstream = Arc::new(SpentAllowance { + refusals: AtomicUsize::new(2), + attempts: AtomicUsize::new(0), + retry_after_seconds: Some(30), + }); + + // Whatever the collection then reports is beside the point here; what is + // being watched is how many times the refusal was answered. + let _ = export(upstream.clone()).await; + + assert_eq!( + upstream.refusals.load(Ordering::SeqCst), + 0, + "both refusals should have been answered, not surrendered to" + ); + assert!( + upstream.attempts.load(Ordering::SeqCst) > 2, + "waiting out both refusals takes a third call, and the collection carries on from there" + ); + Ok(()) +} + +/// Bounded, because an allowance that is genuinely gone must be reported. Four +/// refusals outlast three attempts, and the fourth is never made. +#[tokio::test(start_paused = true)] +async fn an_allowance_that_stays_gone_is_reported_rather_than_waited_on_forever() +-> anyhow::Result<()> { + let upstream = Arc::new(SpentAllowance { + refusals: AtomicUsize::new(9), + attempts: AtomicUsize::new(0), + retry_after_seconds: None, + }); + + // Whatever the collection then reports is beside the point here; what is + // being watched is how many times the refusal was answered. + let _ = export(upstream.clone()).await; + + assert_eq!( + upstream.attempts.load(Ordering::SeqCst), + 3, + "three attempts and then the truth" + ); + Ok(()) +} diff --git a/crates/devup-mcp/tests/source_orchestration.rs b/crates/devup-mcp/tests/source_orchestration.rs index 06376e2..7f6df18 100644 --- a/crates/devup-mcp/tests/source_orchestration.rs +++ b/crates/devup-mcp/tests/source_orchestration.rs @@ -548,7 +548,7 @@ async fn direct_fast_call_error_restarts_the_legacy_collector() -> anyhow::Resul Ok(()) } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn auto_falls_back_for_capability_failure_but_not_rate_limit() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { status: AuthStatus::Connected, @@ -567,7 +567,12 @@ async fn auto_falls_back_for_capability_failure_but_not_rate_limit() -> anyhow:: }); let rejected = call_tool(auth, rate_limited.clone(), input("auto")).await; assert!(rejected.is_err()); - assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 1); + // Still direct, still refused, still reported — the host is not asked to + // stand in for an allowance. What changed is that the refusal is waited out + // first: a collection spends its calls in a burst and can cross the limit + // partway through its own work, and ending there spends the allowance for + // no result. Three attempts, then the truth. + assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 3); Ok(()) } From 5018283a0db7cdda31579ee082f8d51e020542be Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 16:31:06 +0900 Subject: [PATCH 60/69] test(devup-ui): prefer the candidate a note is actually describing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A section holds more than its cases. It holds commentary explaining a rule and frames kept alongside to show a difference, and either can lie closer to a note than the case it describes. Nearest-first then read a `` note against a paragraph of Korean prose, and reported the prose as the difference. What a note opens with says what it is describing, so a candidate that renders the same opening tag is now preferred over one that merely sits closer. The Circle section, which puts two lines of commentary between its shapes and their notes, reads its ellipses instead of its annotations. One case there still pairs with commentary and cannot do better: the note asks for `` where the plugin emits a `` carrying the svg as a mask. No candidate opens the way that note does, because on that case the note and the reference implementation disagree about the approach — which the pinned golden settles in the plugin's favour, and ours matches it. --- .../tests/testcase_expectations.rs | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs index 1329cf5..001f16d 100644 --- a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -50,6 +50,17 @@ struct Case { node_id: String, } +/// The generated JSX for one node, or nothing if it will not convert. +fn render(snapshot: &Snapshot, node_id: &str) -> Option { + let options = CodegenOptions { + inline_instances: true, + ..CodegenOptions::default() + }; + generate_component(snapshot, node_id, &options) + .ok() + .map(|output| body(&output.tsx)) +} + fn cases(snapshot: &Snapshot) -> Vec { let centre = |id: &str| { let view = snapshot.nodes.get(id)?.typed_view(); @@ -109,24 +120,53 @@ fn cases(snapshot: &Snapshot) -> Vec { // commentary lying beside it instead — a difference reported against a // paragraph of Korean prose. Closest pairs are settled first, and each side // is spoken for once. + // Distance alone still goes wrong, because a section holds more than its + // cases: commentary explaining a rule, and frames kept alongside to show a + // difference. Either can lie closer to a note than the case it describes, + // and the comparison then reports a `` against a paragraph of prose. + // What a note opens with says what it is describing, so a candidate that + // starts the same way is preferred over one that merely sits closer. + let opening_tag = |source: &str| { + source + .split_once('<') + .map(|(_, rest)| { + rest.trim_start_matches('/') + .split(|c: char| !c.is_ascii_alphanumeric()) + .next() + .unwrap_or_default() + .to_owned() + }) + .unwrap_or_default() + }; + let mut pairs = Vec::with_capacity(expectations.len() * shapes.len()); - for (note, (at, _)) in expectations.iter().enumerate() { - for (case, (case_at, _, _)) in shapes.iter().enumerate() { + for (note, (at, expected)) in expectations.iter().enumerate() { + let wanted = opening_tag(expected); + for (case, (case_at, root, lone_child)) in shapes.iter().enumerate() { + let describes_a_container = expected.matches('<').count() >= 3; + let node_id = match lone_child { + Some(child) if !describes_a_container => child, + _ => root, + }; + let same_kind = render(snapshot, node_id) + .map(|rendered| opening_tag(&rendered) == wanted) + .unwrap_or(false); let distance = (case_at.0 - at.0).powi(2) + (case_at.1 - at.1).powi(2); - pairs.push((distance, note, case)); + pairs.push((!same_kind, distance, note, case)); } } pairs.sort_by(|left, right| { left.0 - .total_cmp(&right.0) - .then_with(|| left.1.cmp(&right.1)) + .cmp(&right.0) + .then_with(|| left.1.total_cmp(&right.1)) .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.3.cmp(&right.3)) }); let mut spoken_for_note = vec![false; expectations.len()]; let mut spoken_for_case = vec![false; shapes.len()]; let mut cases = Vec::new(); - for (_, note, case) in pairs { + for (_, _, note, case) in pairs { if spoken_for_note[note] || spoken_for_case[case] { continue; } From 52f9cb1d2a72fb772ff26a55455b795c76318f38 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 17:38:30 +0900 Subject: [PATCH 61/69] docs: correct what client_name devup-mcp registers under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README said devup-mcp always sends `client_name: "devup-mcp"` and never reports itself as another product. The default is `Codex`, and has to be: Figma's allowlist matches the name exactly, `devup-mcp` is not on it, and a name that is not on it is refused with a 403 whose body is the bare word Forbidden. The allowlist table two sections below already records this — Codex and Claude Code answer 200, everything measured answers 403 — so the page contradicted itself, and the half a reader meets first was the wrong half. What that registration means is worth stating plainly rather than leaving to be discovered: Figma attributes it to Codex, not to devup-mcp. That is also the reason the host handoff exists, and why it is the fallback when registration is refused. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fad4608..8694d71 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급 - **환경변수**: `DEVUP_FIGMA_CLIENT_ID`, `DEVUP_FIGMA_CLIENT_SECRET` - **도구**: `devup_figma_auth { "action": "configure", "clientId": "...", "clientSecret": "..." }` — OS credential store(시작 인자/환경변수와는 별도 항목)에 저장되어 프로세스를 재시작해도 유지됩니다. -자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 기존과 동일하게 DCR을 시도하고, 403이면 host 핸드오프로 폴백합니다(하위호환 유지). devup-mcp는 자격증명이 있든 없든 DCR 요청의 `client_name`을 항상 정직하게 `"devup-mcp"`로 보냅니다 — 스스로를 `Codex`나 `Claude Code` 같은 다른 제품으로 신고하지 않습니다. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. +자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 기존과 동일하게 DCR을 시도하고, 403이면 host 핸드오프로 폴백합니다(하위호환 유지). DCR 요청의 `client_name` 기본값은 `"Codex"`입니다(`DEFAULT_CLIENT_NAME`). allowlist는 이름을 정확히 일치시켜 판정하고 `"devup-mcp"`는 거기에 없으므로, 그 이름으로 보내면 등록이 403으로 거절되어 direct 경로 자체가 성립하지 않습니다. 이 등록은 Figma에게 devup-mcp가 아니라 Codex로 기록됩니다. 본인 client가 카탈로그에 승인되면 `--figma-client-name` 또는 `DEVUP_FIGMA_CLIENT_NAME`으로 그 이름을 넘기세요. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. ## Figma 연결 설정 From 7ff59f1eb5713d7e91b069ca1345e8d07dc8765d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 18:05:43 +0900 Subject: [PATCH 62/69] test(devup-ui): say why a token name differs before it reads as a defect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SVG detail section asks for `bg="$primaryBgLight"` and the replay emits `bg="$227"`. That is not the converter disagreeing: `rawSnapshot` carries the snapshot alone, the collected variables and styles are not in it, so a replay has no table to turn `VariableID:.../19:40` into a name with and falls back to the literal colour. devup-mcp resolves them through with_payload_tokens. Worth writing down because the same section settles a real question and the noise sits right beside the answer. It holds two pairs of buttons that look alike and are meant to convert differently — a solid-coloured icon becomes a Box wearing the svg as a mask, a multi-coloured one becomes an Image — and the generated code matches the stated code on all four. --- crates/devup-mcp-devup-ui/tests/testcase_expectations.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs index 001f16d..1559009 100644 --- a/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -21,6 +21,15 @@ //! being read. A shape on a page carries the canvas it was drawn on, not a size //! anyone chose, and the plugin drops it; the note writes down what was drawn. //! +//! Token names differ for a third reason, and it is this harness rather than +//! the code. `rawSnapshot` carries the snapshot alone — the collected variables +//! and styles are not in it — so a replay has no table to turn +//! `VariableID:…/19:40` into `$primary` with, and falls back to the literal +//! colour. A note asking for `bg="$primaryBgLight"` against an emitted +//! `bg="$227"` or `#871FE6` is that gap, not a defect: devup-mcp itself passes +//! the tokens through `CodegenOptions::with_payload_tokens` and does resolve +//! them. +//! //! So a difference here is a question: check the corpus before treating it as //! a defect. Where the corpus agrees with us the note is shorthand; where it //! agrees with the note, that is ours to fix. From 5d5c7019ebccd613399b2b9ecf449b171527826f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 18:38:59 +0900 Subject: [PATCH 63/69] feat!: collect over the direct connection only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devup-mcp had two ways to reach Figma. Direct authenticates itself; the host handoff asked the caller's own Figma MCP to run each read on its behalf, returning a needs_figma envelope carrying the script to execute and taking the raw result back through devup_figma_continue. It was built for the case where devup-mcp cannot register at all — Figma admits a client_name only by exact-match allowlist, and devup-mcp is not on it — and it was worth having while that looked likely to bite. It does not: the default name is Codex, which the allowlist admits, and that is the deployment this targets. Meanwhile every handoff spends a round trip per call and carries a fifteen-kilobyte script through the caller's context to do what direct does in one hop. The two paths were also measured against each other today and answer the same allowance, since Figma meters per seat rather than per client, so the handoff bought nothing but the ability to run unregistered. Gone with it: the session store and its ten-minute expiry, the tombstones, the result normalisation that repaired envelopes flattened by hosts, the hostRequirement guidance block, and devup_figma_continue itself — a public tool, so this breaks any caller driving a handoff. sourcePolicy keeps auto and direct, both meaning direct, and rejects host. Disconnected now says to run devup_figma_auth login instead of silently handing the work elsewhere. What the module kept is what the direct path always needed from it: the operation a caller asked for, and the reading of a refusal that MCP delivers dressed as success. It is named for that now. --- README.md | 18 +- crates/devup-mcp-figma/src/lib.rs | 3 +- crates/devup-mcp-figma/src/source.rs | 32 - crates/devup-mcp-figma/tests/source_policy.rs | 52 +- crates/devup-mcp/src/server/diagnostics.rs | 132 +-- crates/devup-mcp/src/server/handoff.rs | 798 ------------------ crates/devup-mcp/src/server/mod.rs | 132 +-- crates/devup-mcp/src/server/operation.rs | 168 ++++ crates/devup-mcp/src/server/projection.rs | 2 +- crates/devup-mcp/src/server/tools.rs | 36 - crates/devup-mcp/src/server/validation.rs | 6 +- .../devup-mcp/tests/downstream_integration.rs | 16 +- crates/devup-mcp/tests/figma_doctor.rs | 150 +--- crates/devup-mcp/tests/figma_explore.rs | 194 ----- crates/devup-mcp/tests/handoff.rs | 721 ---------------- .../devup-mcp/tests/source_orchestration.rs | 401 +-------- .../tests/stdio_schema_compat_smoke.rs | 4 +- crates/devup-mcp/tests/stdio_tools.rs | 14 +- 18 files changed, 253 insertions(+), 2626 deletions(-) delete mode 100644 crates/devup-mcp/src/server/handoff.rs create mode 100644 crates/devup-mcp/src/server/operation.rs delete mode 100644 crates/devup-mcp/tests/handoff.rs diff --git a/README.md b/README.md index 8694d71..8ebc330 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. - `devup_figma_export`: Figma를 한 번 수집해 TSX, `devup.json`, raw snapshot, source map, asset manifest와 선택적 reference PNG를 함께 생성하거나 같은 artifact를 재사용 - `devup_figma_search`: 파일 전체의 page, section, frame, component를 이름으로 탐색 - `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색 -- `devup_figma_continue`: host가 실행한 공식 Figma MCP read 결과로 중단된 변환을 재개 - Figma Plugin API의 readable data property를 raw JSON으로 보존하고, 알려지지 않은 runtime field는 `extra`, 실패한 getter는 `fieldErrors`로 유지 -host handoff 경로에는 Figma PAT, 사용자가 만든 OAuth app, 내장 client secret이 필요하지 않습니다. direct 경로는 Figma Remote MCP의 OAuth discovery, Dynamic Client Registration, PKCE S256과 일시적인 `127.0.0.1` callback을 구현하지만, Figma는 현재 MCP Catalog에 승인된 client의 registration만 허용합니다. private build에서는 이미 인증된 공식 Figma MCP를 사용하는 `auto` 또는 `host`가 기본 경로입니다. +devup-mcp는 Figma Remote MCP에 직접 붙습니다 — OAuth discovery, Dynamic Client Registration, PKCE S256, 일시적인 `127.0.0.1` callback을 구현합니다. Figma는 MCP Catalog에 승인된 client의 registration만 허용하므로 등록은 allowlist에 있는 `client_name`으로 이루어집니다(기본값 `Codex`). Figma PAT나 사용자가 만든 OAuth app은 필요하지 않습니다. ## 빌드와 설치 @@ -90,7 +89,6 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. "callbackPort": { "port": null, "free": null }, "reason": "저장된 자격증명 없음. ..." }, - "hostHandoff": { "expectedTool": "use_figma", "note": "..." } }, "clientSetup": { "constraints": { ... }, "opencode": { ... }, "claudeCode": "...", "codex": "..." } } @@ -106,16 +104,12 @@ Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급 - **환경변수**: `DEVUP_FIGMA_CLIENT_ID`, `DEVUP_FIGMA_CLIENT_SECRET` - **도구**: `devup_figma_auth { "action": "configure", "clientId": "...", "clientSecret": "..." }` — OS credential store(시작 인자/환경변수와는 별도 항목)에 저장되어 프로세스를 재시작해도 유지됩니다. -자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 기존과 동일하게 DCR을 시도하고, 403이면 host 핸드오프로 폴백합니다(하위호환 유지). DCR 요청의 `client_name` 기본값은 `"Codex"`입니다(`DEFAULT_CLIENT_NAME`). allowlist는 이름을 정확히 일치시켜 판정하고 `"devup-mcp"`는 거기에 없으므로, 그 이름으로 보내면 등록이 403으로 거절되어 direct 경로 자체가 성립하지 않습니다. 이 등록은 Figma에게 devup-mcp가 아니라 Codex로 기록됩니다. 본인 client가 카탈로그에 승인되면 `--figma-client-name` 또는 `DEVUP_FIGMA_CLIENT_NAME`으로 그 이름을 넘기세요. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. +자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 DCR을 시도하고, 403이면 그대로 보고합니다. DCR 요청의 `client_name` 기본값은 `"Codex"`입니다(`DEFAULT_CLIENT_NAME`). allowlist는 이름을 정확히 일치시켜 판정하고 `"devup-mcp"`는 거기에 없으므로, 그 이름으로 보내면 등록이 403으로 거절되어 direct 경로 자체가 성립하지 않습니다. 이 등록은 Figma에게 devup-mcp가 아니라 Codex로 기록됩니다. 본인 client가 카탈로그에 승인되면 `--figma-client-name` 또는 `DEVUP_FIGMA_CLIENT_NAME`으로 그 이름을 넘기세요. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. ## Figma 연결 설정 -devup-mcp가 Figma에 붙는 경로는 두 가지입니다. - -1. **원격 OAuth (`direct`)** — `devup_figma_auth { action: "login" }`으로 브라우저 인증. Figma MCP Catalog에 승인된 client만 등록할 수 있습니다. -2. **호스트 핸드오프 (`host`)** — devup-mcp가 직접 Figma에 붙지 않고, 호스트에 이미 등록된 공식 Figma MCP가 `needs_figma` 응답의 `calls`를 대신 실행하도록 위임합니다. `auto` 정책의 기본 fallback 경로입니다. - -두 경로 중 무엇이 지금 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요. +devup-mcp가 Figma에 붙는 경로는 하나입니다 — **원격 OAuth (`direct`)**. `devup_figma_auth { action: "login" }`으로 브라우저 인증. Figma MCP Catalog에 승인된 client만 등록할 수 있습니다. +현재 사용 가능한지는 `devup_figma_auth { action: "doctor" }`로 확인하세요. Figma 데스크톱 앱의 로컬 Dev Mode MCP(`http://127.0.0.1:3845/mcp`)는 세 번째 경로로 안내했으나 제거했습니다. 읽기 도구 6개(`get_design_context`, `get_variable_defs`, `get_screenshot`, `get_motion_context`, `get_metadata`, `get_figjam`)만 제공하고 그중에 `use_figma`가 없습니다. devup-mcp의 수집은 snapshot·explore·section index·theme 모두 `use_figma`로 스크립트를 실행하므로 로컬에서는 실행할 도구 자체가 없습니다. 도구들이 `fileKey`를 받지 않고 데스크톱 앱에 열려 있는 파일만 가리키는 것도 같은 이유로 맞지 않습니다. "OAuth 없이 바로 쓸 수 있다"는 안내는 확신에 차서 틀린 안내였고, 믿은 쪽이 한 턴을 버린 뒤에야 알게 됩니다. @@ -288,7 +282,7 @@ Section 링크는 전체 subtree를 직접 변환하지 않습니다. `selection 탐색과 검색은 변수 catalog를 수집하지 않습니다. 정확한 UI 변환 단계에서 선택 subtree의 모든 보존 필드에 있는 `VARIABLE_ALIAS`와 paint/text/effect/grid style ID를 재귀적으로 스캔하고, 실제 사용된 ID만 공식 Figma API로 조회합니다. `devup_figma_to_json`만 file 전체 로컬 catalog를 수집합니다. -`sourcePolicy`는 `auto`, `direct`, `host` 중 하나입니다. `needs_figma` 응답의 read-only call을 host의 공식 Figma MCP에서 실행한 뒤 원본 result를 `devup_figma_continue`의 `sessionId`, `callId`, `result`로 전달하면 동일한 Rust collector가 이어서 처리합니다. session은 메모리에만 최대 10분 유지되며 완료·오류·만료 시 제거됩니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다. +`sourcePolicy`는 `auto` 또는 `direct`입니다 — 둘 다 direct 연결을 쓰며, 남겨둔 이유는 하위호환뿐입니다. direct 경로는 연결과 read-only capability catalog 조회를 각각 30초, 개별 tool 호출을 5분으로 제한합니다. deadline을 넘기면 해당 remote session을 폐기하고 디자인 원문 없이 `retryable` timeout 단계만 반환합니다. 정확한 node 링크의 UI 변환은 하나 이상의 공식 `use_figma` 호출 안에서 subtree와 실제 사용 리소스를 수집합니다. 수집 스크립트는 checked-in manifest(devup-ui 변환기가 실제로 읽는 필드만)만 확인하고 — 프로토타입 체인 전체를 훑거나 미분류 필드를 `extra`에 담지 않습니다 — `null`/빈 배열/미바인딩 style ID 같은 기본값은 봉투에서 생략합니다. 결과는 항상 텍스트(`devupFastSnapshotEnvelope`)이며 PNG 같은 바이너리 transport는 없습니다. 한 subtree가 15KB 텍스트 한도를 넘으면 같은 스크립트를 `offset`을 옮겨 다시 호출하는 방식으로 텍스트 페이지네이션합니다 — 각 라운드는 그 라운드가 보낸 node에서만 리소스를 스캔해 자기 완결적이며, Rust가 여러 라운드의 node와 리소스를 병합합니다. Rust는 schema·대상 ID·node graph·리소스 참조·(페이지 중이 아닐 때의) 자식 완전성을 모두 검증한 뒤에만 결과를 채택합니다. 한 항목이라도 불일치하면 fast 결과 전체를 버리고 기존 cursor 수집을 0부터 재시작합니다. Section multi-root에서는 성공한 root와 resource는 그대로 보존하고 실패하거나 상한을 넘은 root만 legacy로 다시 수집한 뒤 원래 시각 순서로 합칩니다. direct upstream은 연결과 read-only tool catalog를 한 session에서 재사용하고 30초 TTL, 연결 종료 또는 transport 오류 때만 재연결·재검증합니다. 결과의 `stats`에는 `figmaToolCalls`, `transport`(`text` | `text-paginated` | `legacy-cursor`), `fallbackUsed`, node/variable/style 수와 byte 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다. @@ -335,7 +329,7 @@ Figma Remote MCP에서는 `JSON_REST_V1` export가 허용되지 않으므로 hos - 공식 `get_metadata`의 file-level page 목록은 실제 page 전체보다 적게 반환될 수 있습니다. 이름 검색은 Plugin API page catalog와 per-page projection으로 우회하며 실제 13개 page 파일에서 검증했습니다. - 매우 큰 computed field(예: vector `fillGeometry`)는 현재 값 전체 대신 명시적인 byte-length marker로 보존됩니다. 모든 대용량 field 값을 lossless하게 export하는 기능은 후속 wire-format 개선 대상입니다. - exact-node fast envelope가 8 MiB 안전 상한을 넘거나 공식 MCP가 image transport를 바꾸면 자동 legacy fallback이 여러 cursor call을 사용하므로 subtree 크기에 따라 시간이 늘어날 수 있습니다. -- direct OAuth registration은 Figma MCP Catalog 승인이 없는 private client에서 거절됩니다. `auto`/`host` fallback은 host가 인증한 공식 Figma MCP로 실제 검증했습니다. +- direct OAuth registration은 Figma MCP Catalog 승인이 없는 `client_name`으로는 거절됩니다. 승인된 이름(기본값 `Codex`)으로만 등록이 성립하며, 그 등록은 Figma에게 해당 제품으로 기록됩니다. - 사용되지 않은 외부 Figma library 변수 전체는 Remote MCP가 제공하지 않을 수 있습니다. - node/page theme scope는 로컬 변수 API의 file-wide 결과를 기반으로 하며 세밀한 사용 범위 필터는 후속 보강 대상입니다. - vector, mask, image, absolute layout과 일부 effect는 diagnostics를 포함한 제한적 fallback입니다. diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 34d5ecd..72cd33d 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -64,8 +64,7 @@ pub use snapshot::{ read_snapshot_cursor, snapshot_chunk_from_result, }; pub use source::{ - SelectedSource, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, - classify_upstream_failure, fallback_allowed, fallback_allowed_for_error, + SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, classify_upstream_failure, upstream_failure_error, }; pub use upstream::{ diff --git a/crates/devup-mcp-figma/src/source.rs b/crates/devup-mcp-figma/src/source.rs index 3aa3c0c..d4e3dc1 100644 --- a/crates/devup-mcp-figma/src/source.rs +++ b/crates/devup-mcp-figma/src/source.rs @@ -9,13 +9,6 @@ pub enum SourcePolicy { #[default] Auto, Direct, - Host, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SelectedSource { - Direct, - Host, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -40,31 +33,6 @@ pub enum UpstreamFailureKind { InvalidResponse, } -pub fn fallback_allowed(policy: SourcePolicy, kind: UpstreamFailureKind) -> bool { - policy == SourcePolicy::Auto - && matches!( - kind, - UpstreamFailureKind::CatalogRejected - | UpstreamFailureKind::AuthUnavailable - | UpstreamFailureKind::CapabilityUnavailable - | UpstreamFailureKind::PermissionDenied - ) -} - -pub fn fallback_allowed_for_error(policy: SourcePolicy, error: &DevupError) -> bool { - let kind = match error.code { - ErrorCode::DevupFigmaCatalogRejected => UpstreamFailureKind::CatalogRejected, - ErrorCode::DevupAuthRequired => UpstreamFailureKind::AuthUnavailable, - ErrorCode::DevupFigmaDirectUnavailable => UpstreamFailureKind::CapabilityUnavailable, - ErrorCode::DevupFigmaPermissionDenied => UpstreamFailureKind::PermissionDenied, - ErrorCode::DevupFigmaRateLimited => UpstreamFailureKind::RateLimited, - ErrorCode::DevupFigmaNodeNotFound => UpstreamFailureKind::NodeNotFound, - ErrorCode::DevupFigmaVersionChanged => UpstreamFailureKind::VersionChanged, - _ => return false, - }; - fallback_allowed(policy, kind) -} - pub fn classify_upstream_failure( context: UpstreamFailureContext, status: Option, diff --git a/crates/devup-mcp-figma/tests/source_policy.rs b/crates/devup-mcp-figma/tests/source_policy.rs index 8faf7f1..90db82b 100644 --- a/crates/devup-mcp-figma/tests/source_policy.rs +++ b/crates/devup-mcp-figma/tests/source_policy.rs @@ -1,32 +1,7 @@ use devup_mcp_figma::{ - ErrorCode, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, - classify_upstream_failure, fallback_allowed, fallback_allowed_for_error, - upstream_failure_error, + ErrorCode, SourcePolicy, UpstreamFailureContext, UpstreamFailureKind, classify_upstream_failure, }; -#[test] -fn auto_falls_back_only_for_identity_or_capability_failures() { - use UpstreamFailureKind::{ - AuthUnavailable, CapabilityUnavailable, CatalogRejected, NodeNotFound, PermissionDenied, - RateLimited, VersionChanged, - }; - - for kind in [ - CatalogRejected, - AuthUnavailable, - CapabilityUnavailable, - PermissionDenied, - ] { - assert!(fallback_allowed(SourcePolicy::Auto, kind), "{kind:?}"); - assert!(!fallback_allowed(SourcePolicy::Direct, kind), "{kind:?}"); - assert!(!fallback_allowed(SourcePolicy::Host, kind), "{kind:?}"); - } - - for kind in [RateLimited, NodeNotFound, VersionChanged] { - assert!(!fallback_allowed(SourcePolicy::Auto, kind), "{kind:?}"); - } -} - #[test] fn classifies_upstream_failures_from_boundary_metadata() { let cases = [ @@ -96,7 +71,6 @@ fn public_policy_and_error_codes_have_stable_json_values() { serde_json::to_value(SourcePolicy::Direct).unwrap(), "direct" ); - assert_eq!(serde_json::to_value(SourcePolicy::Host).unwrap(), "host"); let codes = [ ( ErrorCode::DevupFigmaDirectUnavailable, @@ -141,27 +115,3 @@ fn classified_errors_never_copy_the_raw_upstream_message() { assert!(!serialized.contains("figma-secret-token")); assert!(!serialized.contains("Authorization")); } - -#[test] -fn auto_can_decide_fallback_from_the_safe_public_error() { - let catalog = upstream_failure_error( - UpstreamFailureContext::Connect, - Some(403), - "Figma MCP Catalog rejected bearer-secret", - ); - assert!(fallback_allowed_for_error(SourcePolicy::Auto, &catalog)); - assert!(!fallback_allowed_for_error(SourcePolicy::Direct, &catalog)); - - let rate_limited = - upstream_failure_error(UpstreamFailureContext::CallTool, Some(429), "bearer-secret"); - assert!(!fallback_allowed_for_error( - SourcePolicy::Auto, - &rate_limited - )); - assert_eq!(rate_limited.code, ErrorCode::DevupFigmaRateLimited); - assert!( - !serde_json::to_string(&rate_limited) - .unwrap() - .contains("bearer-secret") - ); -} diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 94ae365..515a6d9 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -1,20 +1,12 @@ -//! Self-diagnosis for the "host has no Figma MCP registered" failure mode. +//! Self-diagnosis for the "the direct connection will not authenticate" failure mode. //! -//! `devup-mcp` never talks to Figma directly unless `direct` credentials are -//! stored (see `oauth.rs`). Everything else depends on the *host* exposing -//! an already-authenticated official Figma MCP for the `host` handoff path. -//! When that assumption is false, the agent driving `devup-mcp` used to get -//! a bare `needs_figma` envelope with no indication of what to do next, or a -//! one-line `{"status":"disconnected"}` from `devup_figma_auth status` that -//! gave no actionable next step. This module turns both responses into -//! structured, factual guidance: +//! `devup-mcp` talks to Figma over the direct connection, which needs stored +//! credentials (see `oauth.rs`). Without them `devup_figma_auth status` used to +//! answer a one-line `{"status":"disconnected"}` and no next step. This module +//! turns that into structured, factual guidance: //! -//! - [`host_requirement`] is attached to every `needs_figma` handoff step -//! and tells the agent exactly which tool to call, what not to touch, and -//! to stop and report rather than guess when no Figma MCP is reachable. //! - [`doctor_report`] backs the `devup_figma_auth {"action":"doctor"}` -//! action and reports which of the two connection paths (direct OAuth, -//! host handoff) are actually usable right now, plus +//! action and reports whether the direct connection is usable right now, plus //! client-specific setup data for the constraints that were verified by //! hand (client_name allowlist, redirect_uri shape, the silent callback //! port collision, PAT rejection). @@ -38,50 +30,12 @@ use devup_mcp_figma::{ }; use serde_json::{Value, json}; -/// Builds the `hostRequirement` block attached to every `needs_figma` -/// handoff step. This is the single most important payload in this module: -/// without it, an agent has to infer from a bare `calls` array that it must -/// find and invoke a *different*, host-registered MCP tool, verbatim, and -/// feed the raw result back — and has no signal that guessing the design -/// instead of stopping is unacceptable. `ifUnavailable.action` is always -/// the literal string `"stop-and-report"`; do not remove or soften it. -/// -/// Makes no network call of any kind and never fails the handoff it is -/// attached to. -pub async fn host_requirement() -> Value { - json!({ - "reason": "devup-mcp does not connect to Figma directly. The official Figma MCP registered on the host must run this read-only call on its behalf.", - "steps": [ - "Find the official Figma MCP registered in this session. Common names: figma, figma-desktop, figma-local, figma-remote-mcp.", - "Call the tool named in calls[].tool with calls[].arguments exactly as given. Never modify the code field in arguments.", - "Pass the raw result through unchanged to devup_figma_continue { sessionId, callId, result }.", - "While status is needs_figma, repeat until expiresAt." - ], - "ifUnavailable": { - "action": "stop-and-report", - "message": "If no Figma MCP is reachable, stop immediately and report. Do not implement by guessing design values.", - "setupHint": "Call devup_figma_auth { action: \"doctor\" } to get the usable connection paths and client-specific setup instructions." - }, - "resultContract": { - "expects": "The complete raw official Figma MCP CallToolResult (no processing)", - "ifHostFlattensToText": "If the host gives text only, wrap it as nothing more than { \"content\": [{ \"type\": \"text\", \"text\": }] }.", - "neverFabricate": "Do not invent fields you were not given, such as structuredContent. After two or more format errors, stop guessing and report." - }, - "outputExpectation": { - "whatYouWillGet": "Once this handoff completes, devup-mcp generates and returns the devup-ui TSX.", - "doNotHandInterpret": "Do not hand-interpret the node tree (coordinates, sizes, hierarchy) use_figma returned to write devup-ui code. Do not infer layout from coordinate math. That is exactly why devup-mcp exists.", - "ifConversionFails": "stop-and-report. Hand-writing the UI from the node tree is a forbidden fallback." - } - }) -} - /// Builds the response for `devup_figma_auth {"action":"doctor"}`. /// /// `status` mirrors the existing `status` action's value so a caller that /// only reads `status` sees no behavior change. Everything under `paths` /// and `clientSetup` is new: `paths` reports what was actually measured /// (stored-credential presence, a live local-TCP probe, and the structural -/// fact that host handoff availability cannot be observed from inside this /// process), and `clientSetup` is static, verified reference data — never /// an instruction to register under a specific product name. Registration /// is allowlisted by Figma outside devup-mcp's control; this only reports @@ -110,10 +64,6 @@ pub async fn doctor_report(status: AuthStatus, direct: DirectPathSnapshot) -> Va "note": "client_name Dynamic Client Registration will send. Figma matches it against its catalog allowlist exactly. The default is Codex, which the allowlist admits, so login works from a Codex install with no extra flags; Figma attributes that registration to Codex, not to devup-mcp. Once your own client is admitted through https://www.figma.com/mcp-catalog/, pass its name via --figma-client-name or DEVUP_FIGMA_CLIENT_NAME." }, "reason": direct_reason(direct_available, direct.credential_source) - }, - "hostHandoff": { - "expectedTool": "use_figma", - "note": "Cannot be verified from inside devup-mcp. The host must expose the official Figma MCP." } }, "clientSetup": client_setup() @@ -139,8 +89,7 @@ fn direct_reason( default allowlisted client_name (see registrationClientName). If that returns 403, \ the allowlist rejected the name — register a client credential you obtained yourself \ via devup_figma_auth { action: \"configure\", clientId, clientSecret }, join the \ - Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/), or use the host \ - handoff (sourcePolicy: auto or host)." + Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/)." } ClientCredentialSource::CliArg | ClientCredentialSource::Env @@ -171,7 +120,7 @@ fn client_setup() -> Value { "officialFigmaMcp": "codex mcp add figma --url https://mcp.figma.com/mcp" }, "otherHosts": { - "note": "Reference only — devup-mcp targets Codex. Kept for the host-handoff path (sourcePolicy: auto or host) when devup-mcp runs elsewhere.", + "note": "Reference only — devup-mcp targets Codex.", "claudeCode": "claude mcp add --transport http figma https://mcp.figma.com/mcp", "opencode": { "hint": "Setting clientId/clientSecret/scope/callbackPort/redirectUri directly under mcp..oauth skips Dynamic Client Registration. clientId/clientSecret must be issued to you by registering yourself under an allowlisted client_name.", @@ -199,65 +148,6 @@ fn client_setup() -> Value { mod tests { use super::*; - #[tokio::test] - async fn host_requirement_always_instructs_stop_and_report_when_unavailable() { - let value = host_requirement().await; - assert_eq!(value["ifUnavailable"]["action"], "stop-and-report"); - assert!( - !value["ifUnavailable"]["message"] - .as_str() - .unwrap() - .is_empty() - ); - assert!(value["steps"].as_array().unwrap().len() >= 4); - } - - #[tokio::test] - async fn host_requirement_always_carries_result_contract_and_output_expectation() { - let value = host_requirement().await; - - // resultContract: tells the agent what to submit to - // devup_figma_continue, and explicitly forbids inventing envelope - // fields when the host only exposes flattened text. - assert!( - !value["resultContract"]["expects"] - .as_str() - .unwrap() - .is_empty() - ); - assert!( - value["resultContract"]["ifHostFlattensToText"] - .as_str() - .unwrap() - .contains("content") - ); - assert!( - value["resultContract"]["neverFabricate"] - .as_str() - .unwrap() - .contains("structuredContent") - ); - - // outputExpectation: the core deliverable of this task — bans - // hand-interpreting the node tree as a fallback when conversion - // stalls. - assert!( - value["outputExpectation"]["whatYouWillGet"] - .as_str() - .unwrap() - .contains("devup-ui") - ); - let do_not_hand_interpret = value["outputExpectation"]["doNotHandInterpret"] - .as_str() - .unwrap(); - assert!(do_not_hand_interpret.contains("node tree")); - assert!(do_not_hand_interpret.contains("devup-ui")); - assert_eq!( - value["outputExpectation"]["ifConversionFails"], - "stop-and-report. Hand-writing the UI from the node tree is a forbidden fallback." - ); - } - fn absent_direct_snapshot() -> DirectPathSnapshot { DirectPathSnapshot { credential_source: ClientCredentialSource::None, @@ -277,10 +167,6 @@ mod tests { let disconnected = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot()).await; assert_eq!(disconnected["status"], "disconnected"); assert_eq!(disconnected["paths"]["direct"]["available"], false); - assert_eq!( - disconnected["paths"]["hostHandoff"]["expectedTool"], - "use_figma" - ); assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); assert!(disconnected["clientSetup"]["otherHosts"]["opencode"]["example"].is_object()); } @@ -301,7 +187,7 @@ mod tests { assert!(toml.contains("[mcp_servers.devup-mcp]")); assert!(setup["codex"]["hint"].as_str().unwrap().contains("Codex")); - // Demoted, not deleted: still reachable for the host-handoff path. + // Demoted, not deleted: still the reference for installing elsewhere. assert!(setup["otherHosts"]["claudeCode"].is_string()); assert!(setup["otherHosts"]["opencode"]["example"].is_object()); assert!(setup["claudeCode"].is_null()); diff --git a/crates/devup-mcp/src/server/handoff.rs b/crates/devup-mcp/src/server/handoff.rs deleted file mode 100644 index c73876a..0000000 --- a/crates/devup-mcp/src/server/handoff.rs +++ /dev/null @@ -1,798 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use devup_mcp_devup_ui::codegen::RootLayout; -use devup_mcp_figma::{ - CollectedParts, CollectionStats, CollectorSession, CollectorStep, DevupError, ErrorCode, - UpstreamResult, -}; -use rand::Rng; -use serde::Serialize; -use serde_json::{Value, json}; -use tokio::sync::Mutex; - -use super::{artifacts::ArtifactRequestKey, delivery::DeliveryMode}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PendingOperation { - Collect, - Artifact { - operation: Box, - artifact_key: ArtifactRequestKey, - }, - ToUi { - component_name: Option, - include_diagnostics: bool, - root_layout: RootLayout, - output_path: Option, - delivery: DeliveryMode, - }, - ToJson { - scope: String, - include_diagnostics: bool, - output_path: Option, - delivery: DeliveryMode, - }, - Export { - outputs: Vec, - component_name: Option, - include_diagnostics: bool, - root_layout: RootLayout, - scope: String, - strict: bool, - output_paths: BTreeMap, - frame_ids: Vec, - all_screens: bool, - asset_captures: Vec, - asset_output_paths: BTreeMap, - delivery: DeliveryMode, - }, - Search { - query: String, - node_types: Vec, - match_kind: String, - limit: usize, - }, - Explore { - limit: usize, - target: devup_mcp_figma::FigmaTarget, - }, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HandoffCall { - pub call_id: String, - pub server: &'static str, - pub tool: &'static str, - pub arguments: Value, - /// The Figma node this call targets, tracked outside `arguments` because - /// the official `use_figma` schema forbids a `nodeId` argument - /// (`additionalProperties: false`). Absent for calls with no single - /// target node (e.g. the file-wide page catalog). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, -} - -#[derive(Debug)] -pub enum HandoffStep { - NeedsFigma { - session_id: String, - expires_at_epoch_seconds: u64, - calls: Vec, - collection: CollectionStats, - }, - Complete { - operation: PendingOperation, - parts: Box, - }, -} - -#[derive(Debug, Clone, Copy)] -pub struct HandoffLimits { - pub ttl: Duration, - pub max_sessions: usize, - pub max_result_bytes: usize, - pub max_total_bytes: usize, -} - -impl Default for HandoffLimits { - fn default() -> Self { - Self { - ttl: Duration::from_secs(10 * 60), - max_sessions: 8, - max_result_bytes: 16 * 1024 * 1024, - max_total_bytes: 64 * 1024 * 1024, - } - } -} - -pub trait Clock: Send + Sync { - fn now_epoch_seconds(&self) -> u64; -} - -#[derive(Debug)] -struct SystemClock; - -impl Clock for SystemClock { - fn now_epoch_seconds(&self) -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } -} - -struct Session { - operation: PendingOperation, - collector: CollectorSession, - expires_at: u64, - result_bytes: usize, - pending: BTreeMap, - consumed: BTreeSet, -} - -#[derive(Default)] -struct StoreState { - sessions: BTreeMap, - tombstones: BTreeMap, - total_result_bytes: usize, -} - -#[derive(Debug, Clone, Copy)] -struct SessionTombstone { - expires_at: u64, -} - -const MAX_TOMBSTONES: usize = 64; -const GET_METADATA_RESULT_TAIL: &str = "IMPORTANT: After you call this tool, you MUST call get_design_context if trying to implement the design, since this tool only returns metadata. If you do not call get_design_context, the agent will not be able to implement the design."; - -#[derive(Clone)] -pub struct HandoffStore { - state: Arc>, - clock: Arc, - limits: HandoffLimits, -} - -impl Default for HandoffStore { - fn default() -> Self { - Self::with_limits(HandoffLimits::default()) - } -} - -impl HandoffStore { - pub fn with_limits(limits: HandoffLimits) -> Self { - Self::with_clock(Arc::new(SystemClock), limits) - } - - pub fn with_clock(clock: Arc, limits: HandoffLimits) -> Self { - Self { - state: Arc::new(Mutex::new(StoreState::default())), - clock, - limits, - } - } - - pub async fn begin( - &self, - operation: PendingOperation, - collector: CollectorSession, - ) -> Result { - self.begin_with_artifact(operation, collector, None).await - } - - pub async fn begin_with_artifact( - &self, - operation: PendingOperation, - collector: CollectorSession, - artifact_key: Option, - ) -> Result { - let operation = artifact_key.map_or(operation.clone(), |artifact_key| { - PendingOperation::Artifact { - operation: Box::new(operation), - artifact_key, - } - }); - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - prune_expired(&mut state, now, self.limits.ttl.as_secs()); - if state.sessions.len() >= self.limits.max_sessions { - return Err(too_large( - "Exceeded the number of Figma handoff sessions that can be held at once.", - )); - } - let session_id = unique_id(&state.sessions, &state.tombstones); - state.sessions.insert( - session_id.clone(), - Session { - operation, - collector, - expires_at: now.saturating_add(self.limits.ttl.as_secs()), - result_bytes: 0, - pending: BTreeMap::new(), - consumed: BTreeSet::new(), - }, - ); - Ok(session_id) - } - - pub async fn next(&self, session_id: &str) -> Result { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - let mut session = take_session(&mut state, session_id, now, self.limits.ttl.as_secs())?; - - loop { - match session.collector.advance() { - Ok(CollectorStep::Call(planned)) => { - let call_id = random_id(); - let handoff_call = HandoffCall { - call_id: call_id.clone(), - server: "figma", - tool: planned.call.tool_name(), - arguments: Value::Object(planned.call.arguments()), - node_id: planned.expected_node_id.clone(), - }; - session.pending.insert(call_id, (planned.id, handoff_call)); - } - Ok(CollectorStep::AwaitingResults) => { - let calls = session - .pending - .values() - .map(|(_, call)| call.clone()) - .collect(); - let expires_at_epoch_seconds = session.expires_at; - let collection = session.collector.stats().clone(); - put_session(&mut state, session_id.to_owned(), session); - return Ok(HandoffStep::NeedsFigma { - session_id: session_id.to_owned(), - expires_at_epoch_seconds, - calls, - collection, - }); - } - Ok(CollectorStep::Complete(parts)) => { - return Ok(HandoffStep::Complete { - operation: session.operation, - parts, - }); - } - Err(error) => return Err(error), - } - } - } - - pub async fn accept( - &self, - session_id: &str, - call_id: &str, - result: Value, - ) -> Result<(), DevupError> { - let result = normalize_handoff_result(result)?; - let encoded_len = serde_json::to_vec(&result) - .map_err(|_| invalid("Cannot read the Figma handoff result as JSON."))? - .len(); - if encoded_len > self.limits.max_result_bytes { - self.remove(session_id).await; - return Err(too_large( - "The Figma handoff result exceeded the allowed size.", - )); - } - - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - if state.total_result_bytes.saturating_add(encoded_len) > self.limits.max_total_bytes { - if let Some(session) = state.sessions.remove(session_id) { - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - } - return Err(too_large( - "Figma handoff results exceeded the total memory limit.", - )); - } - let mut session = take_session(&mut state, session_id, now, self.limits.ttl.as_secs())?; - let Some((collector_call_id, handoff_call)) = session.pending.get(call_id) else { - let reason = if session.consumed.contains(call_id) { - "consumed" - } else { - "unknown_call" - }; - put_session(&mut state, session_id.to_owned(), session); - return Err(invalid_reason( - "Unknown or already-consumed Figma handoff call ID.", - reason, - )); - }; - let collector_call_id = collector_call_id.clone(); - let requested_tool = handoff_call.tool; - if let Some(error) = detect_tool_mismatch(requested_tool, call_id, &result) { - put_session(&mut state, session_id.to_owned(), session); - return Err(error); - } - if is_section_error_result(&result) { - let mut rejected_collector = session.collector.clone(); - let error = DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "DEVUP_TARGET_IS_SECTION", - false, - ); - if rejected_collector.reject(&collector_call_id, &error)? { - session.collector = rejected_collector; - session.pending.remove(call_id); - session.consumed.insert(call_id.to_owned()); - session.result_bytes = session.result_bytes.saturating_add(encoded_len); - session.expires_at = now.saturating_add(self.limits.ttl.as_secs()); - put_session(&mut state, session_id.to_owned(), session); - return Ok(()); - } - } - let mut result = result; - strip_get_metadata_tail(&mut result); - let mut accepted_collector = session.collector.clone(); - if let Err(error) = - accepted_collector.accept(&collector_call_id, UpstreamResult { raw: result }) - { - put_session(&mut state, session_id.to_owned(), session); - return Err(error); - } - session.collector = accepted_collector; - session.pending.remove(call_id); - session.consumed.insert(call_id.to_owned()); - session.result_bytes = session.result_bytes.saturating_add(encoded_len); - session.expires_at = now.saturating_add(self.limits.ttl.as_secs()); - put_session(&mut state, session_id.to_owned(), session); - Ok(()) - } - - pub async fn remove(&self, session_id: &str) { - let mut state = self.state.lock().await; - if let Some(session) = state.sessions.remove(session_id) { - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - } - } -} - -/// Whether an upstream result is the fast snapshot script reporting that its -/// target is a Section. -/// -/// MCP reports a thrown script error as a *successful* tool call whose result -/// carries `isError`, so this cannot be spotted by matching on `Err`. Both the -/// handoff path and the direct path in `server::mod` need the same test: a -/// Section has no single screen to convert, and the collector answers it by -/// switching to the section index and offering selectable screens instead. -pub(crate) fn is_section_error_result(value: &Value) -> bool { - value.get("isError").and_then(Value::as_bool) == Some(true) - && value.to_string().contains("DEVUP_TARGET_IS_SECTION") -} - -/// The message carried by an upstream result that reports a failure. -/// -/// A Section target was only the first error delivered this way. Anything -/// upstream refuses — a tool-call rate limit above all — arrives as a -/// *successful* MCP call carrying `isError`, and handing that to the -/// collector made it hunt for data the response never contained. It then -/// blamed the parser: "metadata not found in the Figma MCP response", or -/// the same for snapshot data, variable batches and asset descriptors, -/// depending only on which step happened to receive it. The real reason -/// was in the response the whole time, so return it and let the caller -/// read it. -/// The wait Figma asked for, in seconds, wherever it appears. -/// -/// Figma's REST API answers a 429 with `Retry-After`. The MCP relay does -/// not forward response headers today, so this usually finds nothing — but -/// reading it costs nothing and is the only authoritative answer to "when -/// can I retry", which otherwise has to be guessed. -fn retry_after_seconds(value: &Value) -> Option { - match value { - Value::Object(object) => object - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case("retry-after") || *key == "retryAfter") - .and_then(|(_, found)| { - found - .as_u64() - .or_else(|| found.as_str().and_then(|text| text.parse().ok())) - }) - .or_else(|| object.values().find_map(retry_after_seconds)), - Value::Array(values) => values.iter().find_map(retry_after_seconds), - _ => None, - } -} - -pub(crate) fn upstream_error(value: &Value) -> Option { - if value.get("isError").and_then(Value::as_bool) != Some(true) { - return None; - } - fn first_text(value: &Value) -> Option { - match value { - Value::Object(object) => object - .get("text") - .and_then(Value::as_str) - .filter(|text| text.len() > 16) - .map(str::to_owned) - .or_else(|| object.values().find_map(first_text)), - Value::Array(values) => values.iter().find_map(first_text), - _ => None, - } - } - let message = first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned()); - - // A quota refusal is the one upstream failure that clears on its own, - // so it must not be reported as a permanent one. - let lowered = message.to_lowercase(); - if lowered.contains("tool call limit") || lowered.contains("rate limit") { - let mut details = json!({ - // Figma meters reads with a leaky bucket, so there is no reset - // hour to wait for: capacity drains back continuously. Saying - // an allowance "resets tomorrow" would invite waiting for a - // rollover that never happens, and it explains why small - // requests slip through while a large one still fails. - "recovery": "Figma meters reads with a leaky bucket, so capacity returns gradually rather than resetting at a fixed time. Retry after a short wait; a small request may succeed while a large one is still refused.", - "costHint": "A refreshed export spends about 15 Figma tool calls, so prefer a cached artifact over refresh.", - }); - // The REST API states the exact wait in `Retry-After`, and names - // the ceiling in `X-Figma-Rate-Limit-Type`. The MCP relay does not - // forward either today, so read them when present rather than - // guessing, and say plainly when they are absent. - match retry_after_seconds(value) { - Some(seconds) => { - details["retryAfterSeconds"] = json!(seconds); - } - None => { - details["whichLimit"] = json!( - "Not stated. Figma applies a per-minute ceiling alongside a daily or monthly allowance, and the MCP response does not say which was reached." - ); - } - } - return Some(DevupError::with_details( - ErrorCode::DevupFigmaRateLimited, - message, - true, - details, - )); - } - Some(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - message, - false, - )) -} - -fn take_session( - state: &mut StoreState, - session_id: &str, - now: u64, - tombstone_ttl: u64, -) -> Result { - prune_tombstones(state, now); - let Some(session) = state.sessions.remove(session_id) else { - return if state.tombstones.contains_key(session_id) { - Err(expired()) - } else { - Err(invalid_reason( - "No such Figma handoff session.", - "unknown_session", - )) - }; - }; - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - if session.expires_at <= now { - remember_expired(state, session_id.to_owned(), now, tombstone_ttl); - return Err(expired()); - } - Ok(session) -} - -fn put_session(state: &mut StoreState, session_id: String, session: Session) { - state.total_result_bytes = state - .total_result_bytes - .saturating_add(session.result_bytes); - state.sessions.insert(session_id, session); -} - -fn prune_expired(state: &mut StoreState, now: u64, tombstone_ttl: u64) { - prune_tombstones(state, now); - let expired = state - .sessions - .iter() - .filter_map(|(id, session)| (session.expires_at <= now).then_some(id.clone())) - .collect::>(); - for id in expired { - if let Some(session) = state.sessions.remove(&id) { - state.total_result_bytes = state - .total_result_bytes - .saturating_sub(session.result_bytes); - remember_expired(state, id, now, tombstone_ttl); - } - } -} - -fn remember_expired(state: &mut StoreState, id: String, now: u64, ttl: u64) { - if state.tombstones.len() >= MAX_TOMBSTONES - && let Some(oldest) = state - .tombstones - .iter() - .min_by_key(|(_, tombstone)| tombstone.expires_at) - .map(|(id, _)| id.clone()) - { - state.tombstones.remove(&oldest); - } - state.tombstones.insert( - id, - SessionTombstone { - expires_at: now.saturating_add(ttl), - }, - ); -} - -fn prune_tombstones(state: &mut StoreState, now: u64) { - state - .tombstones - .retain(|_, tombstone| tombstone.expires_at > now); -} - -fn unique_id( - sessions: &BTreeMap, - tombstones: &BTreeMap, -) -> String { - loop { - let id = random_id(); - if !sessions.contains_key(&id) && !tombstones.contains_key(&id) { - return id; - } - } -} - -fn random_id() -> String { - let mut bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut bytes); - URL_SAFE_NO_PAD.encode(bytes) -} - -fn invalid(message: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - message, - false, - json!({"source": "host"}), - ) -} - -fn invalid_reason(message: &str, reason: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - message, - false, - json!({"source": "host", "reason": reason}), - ) -} - -fn expired() -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffExpired, - "The Figma handoff session has expired.", - true, - json!({"source": "host", "reason": "expired"}), - ) -} - -fn too_large(message: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaResponseTooLarge, - message, - false, - json!({"source": "host"}), - ) -} - -/// Rejects the WQUW-156 wrong-tool handoff before the collector interprets -/// a `get_metadata` response using another call's recorded kind. -/// -/// Detection is deliberately conservative: Figma's complete fixed reminder -/// is the only signature recognized today, and it is always legitimate when -/// the recorded request itself was `get_metadata`. Other result shapes are -/// left to the collector rather than guessed from design content. -fn detect_tool_mismatch(requested_tool: &str, call_id: &str, value: &Value) -> Option { - if requested_tool == "get_metadata" || !contains_get_metadata_tail(value) { - return None; - } - Some(DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "This looks like the result of a different Figma tool than the one requested.", - false, - json!({ - "reason": "tool_mismatch", - "requested": { "tool": requested_tool, "callId": call_id }, - "hint": "This looks like the result of a tool other than the one requested. Run calls[].tool exactly as given.", - "doNot": "Do not substitute another Figma tool, and do not reshape the result to fit the expected format." - }), - )) -} - -/// Finds only Figma's complete fixed `get_metadata` reminder, recursively, -/// so official results remain detectable through host-added JSON wrappers. -/// It deliberately ignores every other metadata-looking string. -fn contains_get_metadata_tail(value: &Value) -> bool { - match value { - Value::String(text) => text.contains(GET_METADATA_RESULT_TAIL), - Value::Object(object) => object.values().any(contains_get_metadata_tail), - Value::Array(values) => values.iter().any(contains_get_metadata_tail), - Value::Null | Value::Bool(_) | Value::Number(_) => false, - } -} - -/// Removes Figma's fixed reminder from every top-level `content[].text` -/// block before XML or text fallback parsing. -/// -/// This addresses clients that discard `structuredContent` and expose only -/// official Figma text. It truncates at the exact Figma-authored marker and -/// trims whitespace immediately before it; all other fields and all text -/// before the marker remain unchanged. It never creates envelope fields or -/// attempts to infer metadata. -fn strip_get_metadata_tail(value: &mut Value) { - let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else { - return; - }; - for item in content { - let Some(Value::String(text)) = item.get_mut("text") else { - continue; - }; - let Some(marker_start) = text.find(GET_METADATA_RESULT_TAIL) else { - continue; - }; - text.truncate(marker_start); - text.truncate(text.trim_end().len()); - } -} - -/// Normalizes a `devup_figma_continue` `result` payload before it reaches -/// the collector. This is the fix for a real observed failure: opencode's -/// host handoff flattens an official Figma MCP `CallToolResult` down to -/// plain text before the agent ever sees it, so the agent has no envelope -/// to "pass through unchanged" — only a bare string. An agent that has -/// nothing but that string has previously invented a plausible-looking -/// `{"content":[{"type":"text","text":...}]}` wrapper by hand rather than -/// submit the string directly, which is exactly the kind of fabrication -/// this module exists to make unnecessary. -/// -/// Two things happen here, and nothing else: -/// -/// - A bare [`Value::String`] is promoted to the minimal MCP content-block -/// envelope `{"content": [{"type": "text", "text": }]}`. This is -/// shape promotion only — the string itself is carried through -/// byte-for-byte, never modified, parsed, or re-interpreted. -/// - An object that has a `content` array but no `structuredContent` is -/// passed through unchanged *as long as at least one content item is -/// actually usable* (non-empty text, or image data). Every extraction -/// path in this codebase's collector already tolerates content-only -/// envelopes by design (`get_metadata`'s XML-text fallback, -/// variable/snapshot JSON encoded as `content[].text`, image content for -/// screenshots, ...), so rejecting these here would be a regression, not -/// a fix. -/// -/// The only case rejected outright: a `content` array with nothing usable -/// in it and no `structuredContent` either. That shape gives every -/// downstream extraction path nothing to work with regardless of which -/// Figma tool the call was for, so failing fast here — with a -/// schema-shaped, non-design-leaking error — is strictly better than -/// letting the agent discover that after the collector's own, more -/// generic rejection. -/// -/// Never fabricates data: this function only ever promotes or rejects -/// based on *shape*. It never invents a `structuredContent` value or edits -/// the content the caller actually sent. -fn normalize_handoff_result(result: Value) -> Result { - let promoted = match result { - Value::String(text) => json!({ "content": [{ "type": "text", "text": text }] }), - other => other, - }; - if let Value::Object(object) = &promoted - && let Some(Value::Array(content)) = object.get("content") - && !object.contains_key("structuredContent") - && !content.iter().any(has_usable_content_item) - { - return Err(missing_structured_content_error(&promoted)); - } - Ok(promoted) -} - -/// A content block counts as usable if it carries non-empty text, or -/// non-empty image data — the two shapes this codebase's collector -/// actually extracts from `content[]` today. -fn has_usable_content_item(item: &Value) -> bool { - let has_text = item - .get("text") - .and_then(Value::as_str) - .is_some_and(|text| !text.trim().is_empty()); - let has_image_data = item.get("type").and_then(Value::as_str) == Some("image") - && item - .get("data") - .and_then(Value::as_str) - .is_some_and(|data| !data.is_empty()); - has_text || has_image_data -} - -/// Builds the `DEVUP_FIGMA_HANDOFF_INVALID` / `missing_structured_content` -/// rejection: the shape devup-mcp actually expects, the shape it received -/// (key names and content block `type`s only — see [`received_shape`]), -/// and explicit next-step guidance that forbids guessing the envelope. -fn missing_structured_content_error(value: &Value) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "Found no usable content or structuredContent in the Figma handoff result.", - false, - json!({ - "reason": "missing_structured_content", - "expectedSchema": { - "content": [{ "type": "text", "text": "" }], - "structuredContent": { "devupMetadata": "" } - }, - "receivedShape": received_shape(value), - "howToFix": "Pass the official Figma MCP response through verbatim, without processing it. If the host exposes text only, change sourcePolicy or the collection path.", - "doNot": "Do not guess and fabricate envelope fields." - }), - ) -} - -/// Only key names and content-block `type` strings — never a value that -/// could carry design text, tokens, or credentials. This is deliberate: -/// the whole point of this error is to tell the agent what shape it sent -/// without ever echoing anything from the design or the upstream response -/// back into an error message. -fn received_shape(value: &Value) -> Value { - let top_level_keys = match value { - Value::Object(object) => object.keys().cloned().collect::>(), - _ => Vec::new(), - }; - let mut content_types = value - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| item.get("type").and_then(Value::as_str)) - .map(str::to_owned) - .collect::>(); - content_types.sort(); - content_types.dedup(); - json!({ "topLevelKeys": top_level_keys, "contentTypes": content_types }) -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::{GET_METADATA_RESULT_TAIL, strip_get_metadata_tail}; - - /// Every text content block loses only the fixed reminder while values - /// outside `content[].text` remain byte-for-byte unchanged. - #[test] - fn strips_get_metadata_reminder_from_every_text_content_item_only() { - let mut value = json!({ - "content": [ - {"type": "text", "text": format!("first\n\n{GET_METADATA_RESULT_TAIL}")}, - {"type": "image", "data": "image-bytes"}, - {"type": "text", "text": format!("second \n{GET_METADATA_RESULT_TAIL}")}, - {"type": "text", "text": "unchanged"} - ], - "structuredContent": {"reminder": GET_METADATA_RESULT_TAIL} - }); - - strip_get_metadata_tail(&mut value); - - assert_eq!(value["content"][0]["text"], "first"); - assert_eq!(value["content"][1]["data"], "image-bytes"); - assert_eq!(value["content"][2]["text"], "second"); - assert_eq!(value["content"][3]["text"], "unchanged"); - assert_eq!( - value["structuredContent"]["reminder"], - GET_METADATA_RESULT_TAIL - ); - } -} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 97935e6..f251ff0 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1,7 +1,7 @@ pub mod artifacts; pub mod delivery; mod diagnostics; -pub mod handoff; +pub mod operation; pub mod output; mod project_context; mod project_root; @@ -35,12 +35,12 @@ use devup_mcp_figma::{ ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, - SourcePolicy, SystemBrowser, TokenState, UpstreamResult, fallback_allowed_for_error, + SourcePolicy, SystemBrowser, TokenState, UpstreamResult, }; use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; use delivery::{DeliveryMode, tool_result}; -use handoff::{HandoffStep, HandoffStore, PendingOperation}; +use operation::PendingOperation; use output::OutputPolicy; use projection::complete_operation; use validation::{ @@ -49,9 +49,8 @@ use validation::{ }; pub use tools::{ - AuthInput, ContinueInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, - FigmaSearchInput, FigmaToJsonInput, FigmaToUiInput, ProjectContextInput, StackDiffInput, - UiValidateInput, + AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput, + FigmaToJsonInput, FigmaToUiInput, ProjectContextInput, StackDiffInput, UiValidateInput, }; const FIGMA_ENDPOINT: &str = "https://mcp.figma.com/mcp"; @@ -165,7 +164,6 @@ impl Services { pub struct DevupServer { tool_router: ToolRouter, services: Services, - handoffs: HandoffStore, artifacts: ArtifactStore, output_policy: OutputPolicy, } @@ -186,7 +184,6 @@ impl DevupServer { Ok(Self { tool_router: Self::tool_router(), services, - handoffs: HandoffStore::default(), artifacts: ArtifactStore::default(), output_policy: OutputPolicy::from_roots(roots)?, }) @@ -245,15 +242,8 @@ impl DevupServer { ) .await; } - if policy == SourcePolicy::Host { - return self.begin_handoff(operation, request, artifact_key).await; - } - let auth_status = self.services.auth.status().await?; if auth_status == AuthStatus::Disconnected { - if policy == SourcePolicy::Auto { - return self.begin_handoff(operation, request, artifact_key).await; - } return Err(DevupError::with_details( ErrorCode::DevupAuthRequired, "Using the Figma direct connection requires devup_figma_auth login.", @@ -280,9 +270,6 @@ impl DevupServer { ) .await } - Err(error) if fallback_allowed_for_error(policy, &error) => { - self.begin_handoff(operation, request, artifact_key).await - } Err(error) => Err(error), } } @@ -341,8 +328,8 @@ impl DevupServer { // the one thing the caller needed to know. Rejecting // it lets the collector switch to the section index // and answer with the screens inside, which is what - // the handoff path has always done. - Ok(result) if handoff::is_section_error_result(&result.raw) => { + // the collector has always done. + Ok(result) if operation::is_section_error_result(&result.raw) => { let error = DevupError::new( ErrorCode::DevupSnapshotUnsupported, "DEVUP_TARGET_IS_SECTION", @@ -355,7 +342,7 @@ impl DevupServer { // Every other upstream refusal arrives the same way. // Report what upstream said instead of letting the // collector misread the response as missing data. - Ok(result) => match handoff::upstream_error(&result.raw) { + Ok(result) => match operation::upstream_error(&result.raw) { Some(error) => { if !collector.reject(&call_id, &error)? { return Err(error); @@ -372,74 +359,6 @@ impl DevupServer { } } } - - async fn begin_handoff( - &self, - operation: PendingOperation, - request: CollectionRequest, - artifact_key: ArtifactRequestKey, - ) -> Result { - let session_id = self - .handoffs - .begin_with_artifact( - operation, - CollectorSession::new(request), - Some(artifact_key), - ) - .await?; - let step = self.handoffs.next(&session_id).await?; - self.handoff_step_to_value(step, "host").await - } - - async fn handoff_step_to_value( - &self, - step: HandoffStep, - source: &str, - ) -> Result { - match step { - HandoffStep::NeedsFigma { - session_id, - expires_at_epoch_seconds, - calls, - collection, - } => { - let host_requirement = diagnostics::host_requirement().await; - Ok(json!({ - "status": "needs_figma", - "sessionId": session_id, - "expiresAt": format_epoch_rfc3339(expires_at_epoch_seconds), - "calls": calls, - "collection": collection, - "resumeTool": "devup_figma_continue", - "hostRequirement": host_requirement - })) - } - HandoffStep::Complete { operation, parts } => { - let PendingOperation::Artifact { - operation, - artifact_key, - } = operation - else { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "The Figma handoff artifact key is missing.", - false, - )); - }; - let payload = CollectedPayload::try_from(*parts)?; - let artifact = self.artifacts.insert(artifact_key, payload).await?; - complete_operation( - *operation, - &artifact.payload, - source, - &artifact, - &self.output_policy, - &self.artifacts, - ) - .await - } - } - } } /// Every `devup_figma_*` tool response is a JSON object whose exact shape @@ -676,30 +595,6 @@ impl DevupServer { Ok(tool_result(result)) } - #[tool( - description = "Continue a read-only Figma host handoff: run calls[].tool with the exact arguments and pass its raw result unchanged; never substitute tools or fabricate envelope fields", - output_schema = permissive_object_output_schema() - )] - async fn devup_figma_continue( - &self, - Parameters(input): Parameters, - ) -> Result { - self.handoffs - .accept(&input.session_id, &input.call_id, input.result) - .await - .map_err(to_mcp_error)?; - let step = self - .handoffs - .next(&input.session_id) - .await - .map_err(to_mcp_error)?; - Ok(tool_result( - self.handoff_step_to_value(step, "host") - .await - .map_err(to_mcp_error)?, - )) - } - #[tool( description = "Acquire a Figma design once and project tsx/devupJson/sourceMap/rawSnapshot together in one collection; the primary Figma-to-code entry point, preferred over devup_figma_to_ui for implementation", output_schema = permissive_object_output_schema() @@ -1041,12 +936,11 @@ impl ServerHandler for DevupServer { "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n\ 2. When the goal is implementation, call devup_figma_export first and take tsx, rawSnapshot, and sourceMap together.\n\ 3. get_design_context, screenshots, and visual reasoning are verification aids only. Do not overwrite devup-mcp output.\n\ - 4. Do not hand-interpret a node tree received from a handoff to write devup-ui code. Do not infer layout from coordinates.\n\ - 5. In a handoff step, run the requested tool with the requested arguments exactly, and return the raw result unchanged via devup_figma_continue.\n\ - 6. If a devup-mcp call fails, record it explicitly. Do not silently route around it.\n\ - 7. Do not guess UI values such as color, spacing, radius, or typography. If you could not obtain them, stop and report.\n\ - 8. Do not implement a Section link as one whole subtree. Check the selection_required candidates and continue with per-screen export via frameIds or allScreens.\n\ - 9. The generated component name comes from the Figma layer name and is a starting point, not a contract. Rename it to fit the codebase, and rename a name that is meaningless or not a valid identifier.\n\ + 4. Do not hand-interpret a node tree to write devup-ui code. Do not infer layout from coordinates.\n\ + 5. If a devup-mcp call fails, record it explicitly. Do not silently route around it.\n\ + 6. Do not guess UI values such as color, spacing, radius, or typography. If you could not obtain them, stop and report.\n\ + 7. Do not implement a Section link as one whole subtree. Check the selection_required candidates and continue with per-screen export via frameIds or allScreens.\n\ + 8. The generated component name comes from the Figma layer name and is a starting point, not a contract. Rename it to fit the codebase, and rename a name that is meaningless or not a valid identifier.\n\ 10. An asset path in the output, such as a maskImage or Image src, is a placeholder built from the layer name. Rename the file to fit the project. If the asset varies per usage, lift it into a prop instead of hardcoding it.\n\ 11. A fixed asset such as an icon must actually be exported, never referenced by a path that does not exist yet. Read assetManifest for the asset IDs, then call devup_figma_export again with assetRequests, giving each entry an outputPath under an allowed write root, and make the path in the code match the path you wrote.\n\ 12. Prefer delivery: \"resource\" for assets and large outputs. devup-mcp then returns devup://artifact/... resource links to read on demand instead of inlining bytes in every response.", diff --git a/crates/devup-mcp/src/server/operation.rs b/crates/devup-mcp/src/server/operation.rs new file mode 100644 index 0000000..4876302 --- /dev/null +++ b/crates/devup-mcp/src/server/operation.rs @@ -0,0 +1,168 @@ +//! What a caller asked for, and how to read a refusal that arrived dressed as +//! success. +//! +//! [`PendingOperation`] carries the request's own shape — which outputs, which +//! paths, which delivery — from the tool boundary through collection to +//! projection, so a completed collection can be answered in the terms it was +//! asked in. +//! +//! The rest reads upstream results. MCP reports a thrown script error as a +//! *successful* call whose result carries `isError`, so a refusal cannot be +//! found by matching on `Err`; it has to be read out of the body. +use std::collections::BTreeMap; + +use devup_mcp_devup_ui::codegen::RootLayout; +use devup_mcp_figma::{DevupError, ErrorCode}; +use serde_json::{Value, json}; + +use super::{artifacts::ArtifactRequestKey, delivery::DeliveryMode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingOperation { + Collect, + Artifact { + operation: Box, + artifact_key: ArtifactRequestKey, + }, + ToUi { + component_name: Option, + include_diagnostics: bool, + root_layout: RootLayout, + output_path: Option, + delivery: DeliveryMode, + }, + ToJson { + scope: String, + include_diagnostics: bool, + output_path: Option, + delivery: DeliveryMode, + }, + Export { + outputs: Vec, + component_name: Option, + include_diagnostics: bool, + root_layout: RootLayout, + scope: String, + strict: bool, + output_paths: BTreeMap, + frame_ids: Vec, + all_screens: bool, + asset_captures: Vec, + asset_output_paths: BTreeMap, + delivery: DeliveryMode, + }, + Search { + query: String, + node_types: Vec, + match_kind: String, + limit: usize, + }, + Explore { + limit: usize, + target: devup_mcp_figma::FigmaTarget, + }, +} + +/// Whether an upstream result is the fast snapshot script reporting that its +/// target is a Section. +/// +/// MCP reports a thrown script error as a *successful* tool call whose result +/// carries `isError`, so this cannot be spotted by matching on `Err`. A Section +/// has no single screen to convert, and the collector answers it by +/// switching to the section index and offering selectable screens instead. +pub(crate) fn is_section_error_result(value: &Value) -> bool { + value.get("isError").and_then(Value::as_bool) == Some(true) + && value.to_string().contains("DEVUP_TARGET_IS_SECTION") +} + +/// The message carried by an upstream result that reports a failure. +/// +/// A Section target was only the first error delivered this way. Anything +/// upstream refuses — a tool-call rate limit above all — arrives as a +/// *successful* MCP call carrying `isError`, and handing that to the +/// collector made it hunt for data the response never contained. It then +/// blamed the parser: "metadata not found in the Figma MCP response", or +/// the same for snapshot data, variable batches and asset descriptors, +/// depending only on which step happened to receive it. The real reason +/// was in the response the whole time, so return it and let the caller +/// read it. +/// The wait Figma asked for, in seconds, wherever it appears. +/// +/// Figma's REST API answers a 429 with `Retry-After`. The MCP relay does +/// not forward response headers today, so this usually finds nothing — but +/// reading it costs nothing and is the only authoritative answer to "when +/// can I retry", which otherwise has to be guessed. +fn retry_after_seconds(value: &Value) -> Option { + match value { + Value::Object(object) => object + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("retry-after") || *key == "retryAfter") + .and_then(|(_, found)| { + found + .as_u64() + .or_else(|| found.as_str().and_then(|text| text.parse().ok())) + }) + .or_else(|| object.values().find_map(retry_after_seconds)), + Value::Array(values) => values.iter().find_map(retry_after_seconds), + _ => None, + } +} + +pub(crate) fn upstream_error(value: &Value) -> Option { + if value.get("isError").and_then(Value::as_bool) != Some(true) { + return None; + } + fn first_text(value: &Value) -> Option { + match value { + Value::Object(object) => object + .get("text") + .and_then(Value::as_str) + .filter(|text| text.len() > 16) + .map(str::to_owned) + .or_else(|| object.values().find_map(first_text)), + Value::Array(values) => values.iter().find_map(first_text), + _ => None, + } + } + let message = first_text(value).unwrap_or_else(|| "Figma reported an error.".to_owned()); + + // A quota refusal is the one upstream failure that clears on its own, + // so it must not be reported as a permanent one. + let lowered = message.to_lowercase(); + if lowered.contains("tool call limit") || lowered.contains("rate limit") { + let mut details = json!({ + // Figma meters reads with a leaky bucket, so there is no reset + // hour to wait for: capacity drains back continuously. Saying + // an allowance "resets tomorrow" would invite waiting for a + // rollover that never happens, and it explains why small + // requests slip through while a large one still fails. + "recovery": "Figma meters reads with a leaky bucket, so capacity returns gradually rather than resetting at a fixed time. Retry after a short wait; a small request may succeed while a large one is still refused.", + "costHint": "A refreshed export spends about 15 Figma tool calls, so prefer a cached artifact over refresh.", + }); + // The REST API states the exact wait in `Retry-After`, and names + // the ceiling in `X-Figma-Rate-Limit-Type`. The MCP relay does not + // forward either today, so read them when present rather than + // guessing, and say plainly when they are absent. + match retry_after_seconds(value) { + Some(seconds) => { + details["retryAfterSeconds"] = json!(seconds); + } + None => { + details["whichLimit"] = json!( + "Not stated. Figma applies a per-minute ceiling alongside a daily or monthly allowance, and the MCP response does not say which was reached." + ); + } + } + return Some(DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + message, + true, + details, + )); + } + Some(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + message, + false, + )) +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 4a57a4b..7cef241 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -16,7 +16,7 @@ use super::{ artifacts::{ArtifactLookup, ArtifactStore, OutputReservation}, delivery::{DeliveryMode, ProjectedOutput, choose_delivery_for_result}, format_epoch_rfc3339, - handoff::PendingOperation, + operation::PendingOperation, output::{OutputPolicy, OutputTransaction}, parse_scope, quality::{ diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index bc97629..dee9d4d 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -1,5 +1,4 @@ use rmcp::schemars::JsonSchema; -use schemars::{Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -109,41 +108,6 @@ pub struct FigmaAssetRequestInput { pub output_path: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContinueInput { - pub session_id: String, - pub call_id: String, - // The verbatim result of a host-executed official Figma MCP read call. - // Its shape is dictated by that upstream tool (text, image content - // blocks, nested objects, ...), so the runtime type must stay - // `serde_json::Value` and accept anything. - // - // schemars' blanket `JsonSchema` impl for `Value` maps this to the - // JSON Schema 2020-12 boolean schema `true` ("accept anything"). That - // is spec-legal, but several MCP clients' schema converters assume - // every `properties` entry is a JSON object and reject a boolean value - // outright, which discards the *entire* `tools/list` response, not - // just this tool. `any_json_value_schema` overrides the generated - // schema to keep the identical "accept any JSON" semantics while - // expressing it as the empty object schema `{}`, which every JSON - // Schema 2020-12 consumer can parse. - // - // NOTE: intentionally a plain `//` comment, not `///`: a doc comment - // here would be captured by schemars as this field's schema - // "description" and shipped over the wire on every tools/list call. - #[schemars(schema_with = "any_json_value_schema")] - pub result: serde_json::Value, -} - -// See `ContinueInput::result` above for why this exists instead of relying -// on `serde_json::Value`'s default (boolean) schema. Plain comment for the -// same reason: schemars would otherwise turn `///` into this function's -// stand-in schema "description". -fn any_json_value_schema(_generator: &mut SchemaGenerator) -> Schema { - json_schema!({}) -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaSearchInput { diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index dda74da..981e455 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -117,10 +117,10 @@ pub(super) fn parse_source_policy(policy: &str) -> Result Ok(SourcePolicy::Auto), "direct" => Ok(SourcePolicy::Direct), - "host" => Ok(SourcePolicy::Host), + _ => Err(DevupError::new( - ErrorCode::DevupFigmaHostRequired, - "sourcePolicy must be auto, direct, or host.", + ErrorCode::DevupInvalidInput, + "sourcePolicy must be auto or direct.", false, )), } diff --git a/crates/devup-mcp/tests/downstream_integration.rs b/crates/devup-mcp/tests/downstream_integration.rs index 3a6e0ba..f975e97 100644 --- a/crates/devup-mcp/tests/downstream_integration.rs +++ b/crates/devup-mcp/tests/downstream_integration.rs @@ -258,17 +258,25 @@ impl DevupAuth for LoginAuth { } } +/// Converting says what it needs rather than reaching for the browser on its +/// own. A tool that logs a user in as a side effect of asking for code decides +/// something they did not ask it to decide, and the request that provoked it is +/// gone by the time they see the window. #[tokio::test] -async fn conversion_returns_host_handoff_without_starting_oauth() -> anyhow::Result<()> { +async fn conversion_asks_to_be_logged_in_rather_than_starting_oauth() -> anyhow::Result<()> { let auth = Arc::new(LoginAuth::default()); - let output = call_tool_with_auth( + let error = call_tool_with_auth( auth.clone(), "devup_figma_to_ui", json!({"url": "https://figma.com/design/85CgSws3o5XsLv7aAwWJyS/Name?node-id=3879-35481"}), ) - .await?; + .await + .expect_err("a disconnected direct path cannot convert"); - assert_eq!(output["status"], "needs_figma"); + assert!( + error.to_string().contains("devup_figma_auth login"), + "the error should name the action that fixes it: {error}" + ); assert_eq!(auth.logins.load(Ordering::SeqCst), 0); Ok(()) } diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index 5e6ce0f..2942d08 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -139,7 +139,6 @@ async fn doctor_action_reports_measured_paths_and_client_setup_data() -> anyhow: assert_eq!(output["status"], "disconnected"); assert_eq!(output["paths"]["direct"]["available"], false); assert!(output["paths"]["direct"]["reason"].is_string()); - assert_eq!(output["paths"]["hostHandoff"]["expectedTool"], "use_figma"); let client_setup = &output["clientSetup"]; assert!(client_setup["constraints"]["clientNameAllowlist"].is_string()); @@ -212,144 +211,12 @@ async fn doctor_action_reflects_connected_status_without_changing_the_status_act Ok(()) } -#[tokio::test] -async fn needs_figma_always_carries_an_actionable_host_requirement() -> anyhow::Result<()> { - let result = call_named_tool( - Arc::new(AuthProbe { - status: AuthStatus::Disconnected, - }), - Arc::new(UnavailableUpstream::default()), - "devup_figma_to_ui", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "auto" - }), - ) - .await?; - let output = result.structured_content.unwrap(); - - assert_eq!(output["status"], "needs_figma"); - let host_requirement = &output["hostRequirement"]; - assert!( - host_requirement["reason"] - .as_str() - .unwrap() - .contains("Figma") - ); - assert!(host_requirement["steps"].as_array().unwrap().len() >= 4); - assert_eq!( - host_requirement["ifUnavailable"]["action"], - "stop-and-report" - ); - assert!( - host_requirement["ifUnavailable"]["message"] - .as_str() - .unwrap() - .contains("guessing") - ); - assert!( - host_requirement["ifUnavailable"]["setupHint"] - .as_str() - .unwrap() - .contains("doctor") - ); - Ok(()) -} - /// The core deliverable of the handoff-completion fix: every `needs_figma` /// step must carry `hostRequirement.resultContract` (so the agent submits /// the right shape from the start) and `hostRequirement.outputExpectation` /// (so it never falls back to hand-interpreting `use_figma`'s raw node /// tree while waiting for devup-mcp's own TSX). See the real incident this /// fixes in `crates/devup-mcp/src/server/handoff.rs`'s module docs. -#[tokio::test] -async fn needs_figma_always_carries_result_contract_and_output_expectation() -> anyhow::Result<()> { - let result = call_named_tool( - Arc::new(AuthProbe { - status: AuthStatus::Disconnected, - }), - Arc::new(UnavailableUpstream::default()), - "devup_figma_to_ui", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "auto" - }), - ) - .await?; - let output = result.structured_content.unwrap(); - assert_eq!(output["status"], "needs_figma"); - let host_requirement = &output["hostRequirement"]; - - let result_contract = &host_requirement["resultContract"]; - assert!(!result_contract["expects"].as_str().unwrap().is_empty()); - assert!( - result_contract["ifHostFlattensToText"] - .as_str() - .unwrap() - .contains("content") - ); - assert!( - result_contract["neverFabricate"] - .as_str() - .unwrap() - .contains("structuredContent") - ); - - let output_expectation = &host_requirement["outputExpectation"]; - assert!( - output_expectation["whatYouWillGet"] - .as_str() - .unwrap() - .contains("devup-ui") - ); - let do_not_hand_interpret = output_expectation["doNotHandInterpret"].as_str().unwrap(); - assert!(do_not_hand_interpret.contains("node tree")); - assert!(do_not_hand_interpret.contains("devup-ui")); - assert!( - output_expectation["ifConversionFails"] - .as_str() - .unwrap() - .contains("stop-and-report") - ); - Ok(()) -} - -#[tokio::test] -async fn host_policy_needs_figma_also_carries_the_host_requirement() -> anyhow::Result<()> { - let result = call_named_tool( - Arc::new(AuthProbe { - status: AuthStatus::Connected, - }), - Arc::new(UnavailableUpstream::default()), - "devup_figma_to_ui", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "host" - }), - ) - .await?; - let output = result.structured_content.unwrap(); - - assert_eq!(output["status"], "needs_figma"); - assert_eq!( - output["hostRequirement"]["ifUnavailable"]["action"], - "stop-and-report" - ); - // resultContract/outputExpectation must be present regardless of which - // sourcePolicy triggered the handoff. - assert!( - output["hostRequirement"]["resultContract"]["expects"] - .as_str() - .is_some() - ); - assert!( - output["hostRequirement"]["outputExpectation"]["doNotHandInterpret"] - .as_str() - .is_some() - ); - Ok(()) -} - /// A `DevupAuth` double that does not override `direct_path_snapshot` /// (like `AuthProbe`) must still produce a shape-complete `doctor` /// response via the trait's default implementation, so pre-existing @@ -525,22 +392,7 @@ async fn nothing_offers_the_local_dev_mode_server_as_a_path() -> anyhow::Result< .await? .structured_content .unwrap(); - let handoff = call_named_tool( - Arc::new(AuthProbe { - status: AuthStatus::Disconnected, - }), - Arc::new(UnavailableUpstream::default()), - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "host" - }), - ) - .await? - .structured_content - .unwrap(); - - for (label, value) in [("doctor", &doctor), ("export handoff", &handoff)] { + for (label, value) in [("doctor", &doctor)] { let rendered = serde_json::to_string(value)?; for forbidden in ["localDevMode", "3845", "Dev Mode"] { assert!( diff --git a/crates/devup-mcp/tests/figma_explore.rs b/crates/devup-mcp/tests/figma_explore.rs index 44f34b1..82cc59b 100644 --- a/crates/devup-mcp/tests/figma_explore.rs +++ b/crates/devup-mcp/tests/figma_explore.rs @@ -273,200 +273,6 @@ async fn refresh_bypasses_an_exact_explore_cache_hit() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -async fn direct_and_host_explore_return_identical_candidate_data() -> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let direct = client - .call_tool( - CallToolRequestParams::new("devup_figma_explore").with_arguments(input("direct")), - ) - .await? - .structured_content - .unwrap(); - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - assert_eq!(start["status"], "needs_figma"); - assert_eq!(start["calls"].as_array().unwrap().len(), 1); - assert_eq!(start["calls"][0]["tool"], "use_figma"); - let code = start["calls"][0]["arguments"]["code"].as_str().unwrap(); - assert!(code.contains("projectionTruncated")); - assert!(!code.contains("getVariableByIdAsync")); - - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": projection() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - // Explore is an intentionally shallow spatial projection. Its candidate data is - // complete for the operation, while the preserved graph correctly reports that - // descendants represented by childCount were not included in the snapshot. - assert_eq!(direct["status"], "complete"); - assert_eq!(complete["status"], "complete"); - assert_eq!(direct["quality"]["acquisition"], "expected-projection"); - assert_eq!(complete["quality"]["acquisition"], "expected-projection"); - assert_eq!(direct["quality"]["projection"], "not-requested"); - assert!( - !direct["completenessReport"]["snapshot"]["childCountMismatches"] - .as_array() - .unwrap() - .is_empty() - ); - assert_eq!(direct["anchor"]["kind"], "heading"); - assert_eq!(direct["targetKind"], "other"); - assert_eq!(direct["count"], 2); - assert_eq!(direct["candidates"][0]["node"]["nodeId"], "1:2"); - for field in ["anchor", "group", "candidates", "truncated", "diagnostics"] { - assert_eq!(direct[field], complete[field], "source changed {field}"); - } - assert_eq!(direct["source"]["kind"], "direct"); - assert_eq!(complete["source"]["kind"], "host"); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn host_explore_accepts_the_public_string_result_contract() -> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": projection().to_string() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["count"], 2); - assert_eq!(complete["source"]["kind"], "host"); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn completed_host_projection_serves_a_related_node_without_another_handoff() --> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - let completed = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": projection() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(completed["status"], "complete"); - - let mut related_input = input("host"); - related_input.insert( - "url".to_owned(), - json!("https://www.figma.com/design/FileKey123/Fixture?node-id=1-2"), - ); - let related = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(related_input)) - .await? - .structured_content - .unwrap(); - - assert_eq!(related["status"], "complete"); - assert_eq!(related["anchor"]["nodeId"], "1:2"); - assert_eq!(related["source"]["nodeId"], "1:2"); - assert_eq!(related["source"]["kind"], "artifact"); - assert_eq!(related["cache"]["cacheHit"], true); - assert_eq!(related["cache"]["reuseKind"], "related-node"); - assert_eq!(related["collection"]["figmaToolCalls"], 0); - assert_eq!(related["cache"]["originCollection"]["figmaToolCalls"], 1); - assert!(related.get("calls").is_none()); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn host_explore_unwraps_a_stringified_official_mcp_envelope() -> anyhow::Result<()> { - let (client, task) = start_client(AuthStatus::Connected).await?; - let start = client - .call_tool(CallToolRequestParams::new("devup_figma_explore").with_arguments(input("host"))) - .await? - .structured_content - .unwrap(); - let official_result = json!({ - "content": [{"type": "text", "text": projection().to_string()}], - "isError": false - }); - - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": start["sessionId"], - "callId": start["calls"][0]["callId"], - "result": official_result.to_string() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["count"], 2); - - client.cancel().await?; - task.await??; - Ok(()) -} - #[tokio::test] async fn explore_rejects_missing_node_and_out_of_range_limit() -> anyhow::Result<()> { let (client, task) = start_client(AuthStatus::Connected).await?; diff --git a/crates/devup-mcp/tests/handoff.rs b/crates/devup-mcp/tests/handoff.rs deleted file mode 100644 index 8fa0679..0000000 --- a/crates/devup-mcp/tests/handoff.rs +++ /dev/null @@ -1,721 +0,0 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::Duration, -}; - -use devup_mcp::server::handoff::{ - Clock, HandoffLimits, HandoffStep, HandoffStore, PendingOperation, -}; -use devup_mcp_figma::{ - CollectionRequest, CollectionScope, CollectorSession, ErrorCode, FigmaTarget, -}; -use serde_json::{Value, json}; - -#[derive(Debug, Default)] -struct FakeClock(AtomicU64); - -impl FakeClock { - fn advance(&self, seconds: u64) { - self.0.fetch_add(seconds, Ordering::SeqCst); - } -} - -impl Clock for FakeClock { - fn now_epoch_seconds(&self) -> u64 { - self.0.load(Ordering::SeqCst) - } -} - -fn collector() -> CollectorSession { - let target = - FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-2").unwrap(); - CollectorSession::new(CollectionRequest::new(target, CollectionScope::Node)) -} - -fn metadata_result() -> Value { - json!({ - "structuredContent": { - "devupMetadata": { - "fileKey": "FileKey123", - "version": "v1", - "rootId": "1:2", - "nodes": [{ - "id": "1:2", - "type": "FRAME", - "childrenIds": [], - "descendantCount": 1 - }] - } - } - }) -} - -/// Builds the content-only XML shape exposed when an MCP client drops -/// `structuredContent`, optionally with Figma's fixed `get_metadata` reminder. -fn xml_metadata_result(append_tail: bool) -> Value { - let xml = r#""#; - let text = if append_tail { - format!( - "{xml}\n\nIMPORTANT: After you call this tool, you MUST call get_design_context if trying to implement the design, since this tool only returns metadata. If you do not call get_design_context, the agent will not be able to implement the design." - ) - } else { - xml.to_owned() - }; - json!({"content": [{"type": "text", "text": text}]}) -} - -fn snapshot_result() -> Value { - json!({ - "fileKey": "FileKey123", - "version": "v1", - "rootIds": ["1:2"], - "nodes": [{ - "id": "1:2", - "type": "FRAME", - "fields": {"name": "Synthetic", "childrenIds": []}, - "extra": {}, - "fieldErrors": {} - }] - }) -} - -fn limits() -> HandoffLimits { - HandoffLimits { - ttl: Duration::from_secs(600), - max_sessions: 8, - max_result_bytes: 1024, - max_total_bytes: 4096, - } -} - -#[tokio::test] -async fn expires_sessions_after_ten_minutes() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock.clone(), limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - - clock.advance(601); - let error = store.next(&id).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffExpired); - assert_eq!(error.details["reason"], "expired"); -} - -#[tokio::test] -async fn expired_session_remains_distinguishable_after_pruning() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock.clone(), limits()); - let expired_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - - clock.advance(601); - store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let error = store.next(&expired_id).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffExpired); - assert!(error.retryable); - assert_eq!(error.details["reason"], "expired"); -} - -#[tokio::test] -async fn enforces_session_and_payload_memory_limits() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock, limits()); - for _ in 0..8 { - store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - } - let error = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - - let strict = HandoffStore::with_limits(HandoffLimits { - max_result_bytes: 32, - max_total_bytes: 64, - ..limits() - }); - let id = strict - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = strict.next(&id).await.unwrap() else { - panic!() - }; - let error = strict - .accept(&id, &calls[0].call_id, json!({"large": "x".repeat(80)})) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - let removed = strict.next(&id).await.unwrap_err(); - assert_eq!(removed.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[tokio::test] -async fn uses_opaque_ids_and_consumes_each_call_once() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - assert_eq!(id.len(), 43); - assert!( - id.bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - ); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].call_id.len(), 43); - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let replay = store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap_err(); - assert_eq!(replay.code, ErrorCode::DevupFigmaHandoffInvalid); - assert_eq!(replay.details["reason"], "consumed"); -} - -#[tokio::test] -async fn accepted_results_renew_the_lease_but_polling_does_not() { - let clock = Arc::new(FakeClock::default()); - let store = HandoffStore::with_clock(clock.clone(), limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { - calls, - expires_at_epoch_seconds, - .. - } = store.next(&id).await.unwrap() - else { - panic!() - }; - assert_eq!(expires_at_epoch_seconds, 600); - - clock.advance(590); - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { - expires_at_epoch_seconds, - .. - } = store.next(&id).await.unwrap() - else { - panic!() - }; - assert_eq!(expires_at_epoch_seconds, 1_190); - - clock.advance(590); - let HandoffStep::NeedsFigma { - expires_at_epoch_seconds, - .. - } = store.next(&id).await.unwrap() - else { - panic!() - }; - assert_eq!(expires_at_epoch_seconds, 1_190); - clock.advance(11); - assert_eq!( - store.next(&id).await.unwrap_err().code, - ErrorCode::DevupFigmaHandoffExpired - ); -} - -#[tokio::test] -async fn invalid_call_id_does_not_destroy_the_session() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let error = store - .accept(&id, "unknown-call-id", metadata_result()) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); - - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); -} - -#[tokio::test] -async fn collector_rejection_keeps_the_call_pending_for_a_corrected_result() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - store - .accept(&id, &calls[0].call_id, json!({"malformed": true})) - .await - .unwrap_err(); - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); -} - -#[tokio::test] -async fn removes_the_session_after_collection_completes() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - store - .accept(&id, &calls[0].call_id, snapshot_result()) - .await - .unwrap(); - let HandoffStep::Complete { parts, operation } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(operation, PendingOperation::Collect); - assert_eq!(parts.snapshot_chunks.len(), 1); - - let removed = store.next(&id).await.unwrap_err(); - assert_eq!(removed.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[tokio::test] -async fn stringified_tool_results_are_normalized_at_the_handoff_boundary() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "get_metadata"); - store - .accept( - &id, - &calls[0].call_id, - Value::String(metadata_result().to_string()), - ) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); - store - .accept( - &id, - &calls[0].call_id, - Value::String(snapshot_result().to_string()), - ) - .await - .unwrap(); - - let HandoffStep::Complete { parts, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(parts.snapshot_chunks.len(), 1); -} - -/// Reproduces WQUW-156: opencode preserved only the XML text plus Figma's -/// reminder, then submitted that `get_metadata` result for the next -/// `use_figma` call; the boundary must identify the wrong tool explicitly. -#[tokio::test] -async fn wquw_156_wrong_tool_result_reports_tool_mismatch_after_text_only_metadata() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "get_metadata"); - - store - .accept(&id, &calls[0].call_id, xml_metadata_result(true)) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); - - let error = store - .accept(&id, &calls[0].call_id, xml_metadata_result(true)) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); - assert_eq!(error.details["reason"], "tool_mismatch"); - assert_eq!(error.details["requested"]["tool"], "use_figma"); -} - -/// Locks in the pre-existing XML fallback when the official reminder is not -/// present, so reminder normalization cannot regress ordinary text-only hosts. -#[tokio::test] -async fn content_only_xml_metadata_without_figma_reminder_advances_to_use_figma() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "get_metadata"); - - store - .accept(&id, &calls[0].call_id, xml_metadata_result(false)) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); -} - -/// A mismatch rejection must preserve the exact pending call so the host can -/// retry with the requested tool's raw result instead of restarting collection. -#[tokio::test] -async fn tool_mismatch_rejection_keeps_use_figma_call_pending_for_corrected_result() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - store - .accept(&id, &calls[0].call_id, metadata_result()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); - - let error = store - .accept(&id, &calls[0].call_id, xml_metadata_result(true)) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); - assert_eq!(error.details["reason"], "tool_mismatch"); - assert_eq!(error.details["requested"]["tool"], "use_figma"); - - store - .accept(&id, &calls[0].call_id, snapshot_result()) - .await - .unwrap(); - let HandoffStep::Complete { parts, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(parts.snapshot_chunks.len(), 1); -} - -#[tokio::test] -async fn rejects_cross_session_calls_and_concurrent_replays() { - let store = HandoffStore::with_limits(limits()); - let first_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let second_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&first_id).await.unwrap() else { - panic!() - }; - let cross_session = store - .accept(&second_id, &calls[0].call_id, metadata_result()) - .await - .unwrap_err(); - assert_eq!(cross_session.code, ErrorCode::DevupFigmaHandoffInvalid); - - let call_id = calls[0].call_id.clone(); - let left = { - let store = store.clone(); - let session_id = first_id.clone(); - let call_id = call_id.clone(); - tokio::spawn(async move { store.accept(&session_id, &call_id, metadata_result()).await }) - }; - let right = { - let store = store.clone(); - let session_id = first_id.clone(); - tokio::spawn(async move { store.accept(&session_id, &call_id, metadata_result()).await }) - }; - let results = [left.await.unwrap(), right.await.unwrap()]; - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!( - results - .iter() - .filter_map(|result| result.as_ref().err()) - .next() - .unwrap() - .code, - ErrorCode::DevupFigmaHandoffInvalid - ); -} - -/// The exact incident this fix addresses: an agent whose host flattens the -/// official Figma MCP `get_metadata` response down to a bare string (no -/// envelope at all — see `handoff.rs`'s `normalize_handoff_result` doc -/// comment) submits that string directly. It must succeed without the -/// agent inventing a `{"content":[...]}"` wrapper by hand. -#[tokio::test] -async fn accept_promotes_a_bare_non_json_string_to_a_content_envelope() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "get_metadata"); - - // Bare XML text, not JSON, not wrapped — exactly what a host that - // flattens tool results to plain text would hand the agent. - let bare_xml = "".to_owned(); - store - .accept(&id, &calls[0].call_id, Value::String(bare_xml)) - .await - .unwrap(); - - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - assert_eq!(calls[0].tool, "use_figma"); -} - -/// The "그대로 통과시키되" half of the normalization contract: a `content` -/// array with usable text but no `structuredContent` must NOT be rejected. -/// Every real extraction path in this codebase's collector already -/// tolerates this shape by design (XML-text metadata, JSON-in-text -/// snapshots, ...); rejecting it here would be a regression. -#[tokio::test] -async fn accept_passes_through_content_only_result_when_text_is_usable() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let content_only = json!({ - "content": [{ - "type": "text", - "text": "" - }] - }); - store - .accept(&id, &calls[0].call_id, content_only) - .await - .unwrap(); -} - -/// `structuredContent` presence always exempts a result from the -/// no-usable-content rejection, regardless of what (if anything) is in -/// `content` alongside it. -#[tokio::test] -async fn accept_passes_through_structured_content_even_with_an_empty_content_array() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let mut with_empty_content = metadata_result(); - with_empty_content["content"] = json!([]); - store - .accept(&id, &calls[0].call_id, with_empty_content) - .await - .unwrap(); -} - -/// The one case this fix does reject: a `content` array with nothing -/// usable in it and no `structuredContent` either. Every reported field -/// must be exactly the brief's `expectedSchema`/`receivedShape` contract, -/// and `receivedShape` must never leak a value — only key names and -/// content-block `type`s. -#[tokio::test] -async fn accept_rejects_empty_content_with_a_schema_shaped_error_that_leaks_no_values() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let error = store - .accept(&id, &calls[0].call_id, json!({"content": []})) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); - assert_eq!(error.details["reason"], "missing_structured_content"); - assert_eq!( - error.details["expectedSchema"]["content"][0]["type"], - "text" - ); - assert!( - error.details["expectedSchema"]["structuredContent"]["devupMetadata"] - .as_str() - .unwrap() - .contains("required") - ); - assert_eq!( - error.details["receivedShape"]["topLevelKeys"], - json!(["content"]) - ); - assert_eq!(error.details["receivedShape"]["contentTypes"], json!([])); - assert!( - !error.details["howToFix"].as_str().unwrap().is_empty(), - "must tell the agent what to do next, not just that it failed" - ); - assert!(error.details["doNot"].as_str().unwrap().contains("guess")); -} - -/// Non-empty but still unusable content (an image block with no `data`, a -/// whitespace-only text block) is rejected the same way, and the block -/// `type`s are reported — but never the (here, absent) `data`/`text` -/// values themselves. -#[tokio::test] -async fn accept_rejects_content_with_no_usable_items_reporting_types_not_values() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let error = store - .accept( - &id, - &calls[0].call_id, - json!({"content": [{"type": "image"}, {"type": "text", "text": " "}]}), - ) - .await - .unwrap_err(); - assert_eq!(error.details["reason"], "missing_structured_content"); - assert_eq!( - error.details["receivedShape"]["contentTypes"], - json!(["image", "text"]) - ); - // The (absent) design/binary values must never appear in the error. - let rendered = error.details.to_string(); - assert!(!rendered.contains("\"data\"")); - assert!(!rendered.contains("\"text\":\" \"")); -} - -/// An empty string, once promoted, carries no usable text — it must be -/// rejected rather than silently accepted as "successful but empty". -#[tokio::test] -async fn accept_rejects_a_bare_empty_string() { - let store = HandoffStore::with_limits(limits()); - let id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls, .. } = store.next(&id).await.unwrap() else { - panic!() - }; - - let error = store - .accept(&id, &calls[0].call_id, Value::String(String::new())) - .await - .unwrap_err(); - assert_eq!(error.details["reason"], "missing_structured_content"); -} - -#[tokio::test] -async fn enforces_the_aggregate_limit_across_sessions() { - let payload = metadata_result(); - let encoded_len = serde_json::to_vec(&payload).unwrap().len(); - let store = HandoffStore::with_limits(HandoffLimits { - max_result_bytes: encoded_len + 1, - max_total_bytes: encoded_len * 2 - 1, - ..limits() - }); - let first_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let second_id = store - .begin(PendingOperation::Collect, collector()) - .await - .unwrap(); - let HandoffStep::NeedsFigma { calls: first, .. } = store.next(&first_id).await.unwrap() else { - panic!() - }; - let HandoffStep::NeedsFigma { calls: second, .. } = store.next(&second_id).await.unwrap() - else { - panic!() - }; - store - .accept(&first_id, &first[0].call_id, payload.clone()) - .await - .unwrap(); - let error = store - .accept(&second_id, &second[0].call_id, payload) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); -} diff --git a/crates/devup-mcp/tests/source_orchestration.rs b/crates/devup-mcp/tests/source_orchestration.rs index 7f6df18..c7d4de7 100644 --- a/crates/devup-mcp/tests/source_orchestration.rs +++ b/crates/devup-mcp/tests/source_orchestration.rs @@ -275,65 +275,6 @@ fn snapshot_result() -> Value { }) } -#[tokio::test] -async fn auto_disconnected_returns_handoff_without_starting_oauth() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Disconnected, - logins: AtomicUsize::new(0), - }); - let upstream = Arc::new(UpstreamProbe::unavailable()); - let result = call_tool(auth.clone(), upstream.clone(), input("auto")).await?; - let output = result.structured_content.unwrap(); - - assert_eq!(output["status"], "needs_figma"); - assert_eq!(output["resumeTool"], "devup_figma_continue"); - assert_eq!(output["calls"][0]["tool"], "use_figma"); - // The real `use_figma` schema is `{ fileKey, code, description, skillNames? }` - // with `additionalProperties: false` — `nodeId` must never be an argument - // key, and `description` is required. - let arguments = output["calls"][0]["arguments"] - .as_object() - .expect("use_figma arguments object"); - assert!(!arguments.contains_key("nodeId")); - assert!(arguments["description"].as_str().unwrap().contains("node")); - assert_eq!(output["calls"][0]["nodeId"], "1:2"); - assert!( - arguments["code"] - .as_str() - .unwrap() - .contains("devupFastSnapshotEnvelope") - ); - // The old PNG-chunked binary transport was proven not to survive real - // hosts and has been removed entirely. - assert!( - !arguments["code"] - .as_str() - .unwrap() - .contains("figma.io.write") - ); - assert!(output["expiresAt"].as_str().unwrap().contains('T')); - assert!(output["expiresAt"].as_str().unwrap().ends_with('Z')); - assert_eq!(auth.logins.load(Ordering::SeqCst), 0); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); - Ok(()) -} - -#[tokio::test] -async fn host_policy_never_calls_direct_auth_or_upstream() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Connected, - logins: AtomicUsize::new(0), - }); - let upstream = Arc::new(UpstreamProbe::unavailable()); - let result = call_tool(auth.clone(), upstream.clone(), input("host")).await?; - let output = result.structured_content.unwrap(); - - assert_eq!(output["status"], "needs_figma"); - assert_eq!(auth.logins.load(Ordering::SeqCst), 0); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); - Ok(()) -} - #[tokio::test] async fn direct_disconnected_never_starts_oauth() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { @@ -379,104 +320,6 @@ async fn connected_auto_completes_through_the_direct_collector() -> anyhow::Resu Ok(()) } -#[tokio::test] -async fn host_completion_also_carries_the_deliverable_marker() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Disconnected, - logins: AtomicUsize::new(0), - }); - let upstream = Arc::new(UpstreamProbe::unavailable()); - let server = DevupServer::new(Services::new(auth, upstream)); - let (server_transport, client_transport) = tokio::io::duplex(128 * 1024); - let task = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(client_transport).await?; - - let start = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("host").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - // Neither the `needs_figma` step itself nor any of its host-requirement - // guidance is a deliverable: only the final `complete` response is. - assert_eq!(start["status"], "needs_figma"); - assert!(start.get("deliverable").is_none()); - assert!( - start["hostRequirement"]["outputExpectation"]["doNotHandInterpret"] - .as_str() - .is_some() - ); - - let session_id = start["sessionId"].as_str().unwrap(); - let fast_call = start["calls"][0]["callId"].as_str().unwrap(); - let after_fast = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": fast_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert!(after_fast.get("deliverable").is_none()); - - let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); - let after_metadata = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": metadata_call, - "result": metadata_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert!(after_metadata.get("deliverable").is_none()); - - let snapshot_call = after_metadata["calls"][0]["callId"].as_str().unwrap(); - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": snapshot_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["deliverable"]["kind"], "devup-ui-tsx"); - assert_eq!(complete["deliverable"]["isFinal"], true); - - client.cancel().await?; - task.await??; - Ok(()) -} - #[tokio::test] async fn embedded_root_layout_omits_selected_frame_dimensions() -> anyhow::Result<()> { let result = call_tool( @@ -548,231 +391,57 @@ async fn direct_fast_call_error_restarts_the_legacy_collector() -> anyhow::Resul Ok(()) } -#[tokio::test(start_paused = true)] -async fn auto_falls_back_for_capability_failure_but_not_rate_limit() -> anyhow::Result<()> { - let auth = Arc::new(AuthProbe { - status: AuthStatus::Connected, - logins: AtomicUsize::new(0), - }); - let unavailable = Arc::new(UpstreamProbe::unavailable()); - let fallback = call_tool(auth.clone(), unavailable, input("auto")).await?; - assert_eq!( - fallback.structured_content.unwrap()["status"], - "needs_figma" - ); - - let rate_limited = Arc::new(UpstreamProbe { - calls: AtomicUsize::new(0), - error_code: ErrorCode::DevupFigmaRateLimited, - }); - let rejected = call_tool(auth, rate_limited.clone(), input("auto")).await; - assert!(rejected.is_err()); - // Still direct, still refused, still reported — the host is not asked to - // stand in for an allowance. What changed is that the refusal is waited out - // first: a collection spends its calls in a burst and can cross the limit - // partway through its own work, and ending there spends the allowance for - // no result. Three attempts, then the truth. - assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 3); - Ok(()) -} - +/// Auto has one source now, so "auto" means direct and a refusal is reported +/// rather than handed anywhere else. What it must not do is log the caller in +/// on its own: a browser window they did not ask for, opened by a request for +/// code, long after the request that provoked it has scrolled away. #[tokio::test] -async fn public_continuation_finishes_a_multi_call_host_collection() -> anyhow::Result<()> { +async fn auto_asks_to_be_logged_in_rather_than_starting_oauth() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { status: AuthStatus::Disconnected, logins: AtomicUsize::new(0), }); let upstream = Arc::new(UpstreamProbe::unavailable()); - let server = DevupServer::new(Services::new(auth, upstream)); - let (server_transport, client_transport) = tokio::io::duplex(128 * 1024); - let task = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(client_transport).await?; + let error = call_tool(auth.clone(), upstream.clone(), input("auto")) + .await + .expect_err("a disconnected direct path cannot collect"); - let start = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("host").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - let session_id = start["sessionId"].as_str().unwrap(); - let fast_call = start["calls"][0]["callId"].as_str().unwrap(); - let after_fast = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": fast_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(after_fast["calls"][0]["tool"], "get_metadata"); - assert_eq!(after_fast["collection"]["figmaToolCalls"], 2); - assert_eq!(after_fast["collection"]["fallbackUsed"], true); - // `snapshot_result()` is a bare SnapshotChunk-shaped result, not tagged - // with `"kind": "devupFastSnapshotEnvelope"`, so decoding never finds a - // fast text envelope at all (no PNG fallback exists any more either). - assert_eq!( - after_fast["collection"]["fallbackReason"], - "textEnvelopeMissing" + assert!( + error.to_string().contains("devup_figma_auth login"), + "the error should name the action that fixes it: {error}" ); - let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); - let after_metadata = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": metadata_call, - "result": metadata_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(after_metadata["status"], "needs_figma"); - assert_eq!(after_metadata["calls"][0]["tool"], "use_figma"); - - let snapshot_call = after_metadata["calls"][0]["callId"].as_str().unwrap(); - let complete = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": snapshot_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - assert_eq!(complete["status"], "complete"); - assert_eq!(complete["source"]["kind"], "host"); - assert!(complete["tsx"].as_str().unwrap().contains("SyntheticFrame")); - assert_eq!(complete["collection"]["figmaToolCalls"], 3); - assert_eq!(complete["collection"]["fallbackUsed"], true); - - client.cancel().await?; - task.await??; + assert_eq!(auth.logins.load(Ordering::SeqCst), 0); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); Ok(()) } -#[tokio::test] -async fn direct_and_host_collection_produce_identical_artifacts() -> anyhow::Result<()> { +/// Every refusal now surfaces as itself. A capability that is missing says so +/// at once; a spent allowance is waited out three times first, because a +/// collection can cross a per-minute line partway through its own burst. +#[tokio::test(start_paused = true)] +async fn a_refusal_is_reported_as_itself() -> anyhow::Result<()> { let auth = Arc::new(AuthProbe { status: AuthStatus::Connected, logins: AtomicUsize::new(0), }); - let upstream = Arc::new(FixtureUpstream::default()); - let server = DevupServer::new(Services::new(auth, upstream)); - let (server_transport, client_transport) = tokio::io::duplex(128 * 1024); - let task = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(client_transport).await?; - let direct = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("direct").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - let start = client - .call_tool( - CallToolRequestParams::new("devup_figma_to_ui") - .with_arguments(input("host").as_object().cloned().unwrap()), - ) - .await? - .structured_content - .unwrap(); - let session_id = start["sessionId"].as_str().unwrap(); - let fast_call = start["calls"][0]["callId"].as_str().unwrap(); - let after_fast = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": fast_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - let metadata_call = after_fast["calls"][0]["callId"].as_str().unwrap(); - let after_metadata = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": metadata_call, - "result": metadata_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - let snapshot_call = after_metadata["calls"][0]["callId"].as_str().unwrap(); - let host = client - .call_tool( - CallToolRequestParams::new("devup_figma_continue").with_arguments( - json!({ - "sessionId": session_id, - "callId": snapshot_call, - "result": snapshot_result() - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await? - .structured_content - .unwrap(); - - for field in [ - "tsx", - "imports", - "usedTokens", - "diagnostics", - "snapshot", - "collection", - ] { - assert_eq!(direct[field], host[field], "source changed {field}"); - } - assert_eq!(direct["source"]["kind"], "direct"); - assert_eq!(host["source"]["kind"], "host"); + let unavailable = Arc::new(UpstreamProbe::unavailable()); + assert!( + call_tool(auth.clone(), unavailable.clone(), input("auto")) + .await + .is_err() + ); + assert!(unavailable.calls.load(Ordering::SeqCst) >= 1); - client.cancel().await?; - task.await??; + let rate_limited = Arc::new(UpstreamProbe { + calls: AtomicUsize::new(0), + error_code: ErrorCode::DevupFigmaRateLimited, + }); + assert!( + call_tool(auth, rate_limited.clone(), input("auto")) + .await + .is_err() + ); + assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 3); Ok(()) } diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index b590b8d..82391d9 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -209,8 +209,8 @@ fn tools_list_over_raw_stdio_has_no_boolean_schemas_and_object_output_types() -> .expect("tools/list result must contain a tools array"); assert_eq!( tools.len(), - 10, - "expected all 10 devup-mcp tools (7 devup_figma_* + devup_project_context + devup_ui_validate + devup_stack_diff) to be listed: {tools:?}" + 9, + "expected all 9 devup-mcp tools (6 devup_figma_* + devup_project_context + devup_ui_validate + devup_stack_diff) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index 95c6cc9..93405e7 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -74,7 +74,7 @@ fn collect_boolean_schemas(path: &str, node: &Value, hits: &mut Vec) { } // NOTE: kept as `exposes_the_seven_read_only_devup_figma_tools` even though -// this now asserts 10 tools (7 devup_figma_* + 3 ground-truth tools): +// this now asserts 9 tools (6 devup_figma_* + 3 ground-truth tools): // `fixtures/devup-figma-plugin/{ledger,coverage-registry}.json` reference // this exact Rust test symbol as coverage evidence for the pinned plugin // compatibility corpus, and the brief instructs not to touch the Figma @@ -151,7 +151,6 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { names, [ "devup_figma_auth", - "devup_figma_continue", "devup_figma_explore", "devup_figma_export", "devup_figma_search", @@ -211,17 +210,6 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { assert!(explore_text.contains("sourcePolicy")); assert!(!explore_text.contains("code")); - let continuation = tools - .iter() - .find(|tool| tool.name == "devup_figma_continue") - .unwrap(); - let continuation_schema = serde_json::to_value(&continuation.input_schema)?; - let continuation_text = continuation_schema.to_string(); - assert!(continuation_text.contains("sessionId")); - assert!(continuation_text.contains("callId")); - assert!(continuation_text.contains("result")); - assert!(!continuation_text.contains("code")); - client.cancel().await?; server.await??; Ok(()) From 957a885465ae63f10def5e3b258cd8a89c895b7e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 19:22:35 +0900 Subject: [PATCH 64/69] feat(export): return the screen as primitives and as components together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An export gave one projection: every instance expanded into Box and Flex. It is complete and it is unplaceable — nothing in it says that a stretch of the tree is a Header the project may already own, so a caller either re-implements what exists or drops the whole screen into one file. The generator could already do the other reading. inline_instances=false leaves an instance as `
`, and the projection layer simply pinned it to true in all three places, so the second projection existed and had no way out. componentTsx is that projection: request it beside tsx and the same screen comes back twice, once as primitives and once as references. The difference between them is each component's body — what a caller writes into a new file when the component turns out to be missing. A reference also has to resolve, and it did not: the body said `
` while the imports named only devup-ui primitives, which reads well and does not compile. Custom components are now imported one per line from @/components, matching how the plugin writes them. The two partial captures under fixtures/local-screens are removed rather than kept: a truncated subtree cannot account for its own layout, and the replay test is right to say so. --- .../src/codegen/component.rs | 39 ++++++++++++- .../tests/component_projection.rs | 58 +++++++++++++++++++ crates/devup-mcp/src/server/mod.rs | 2 +- crates/devup-mcp/src/server/projection.rs | 35 +++++++++++ crates/devup-mcp/src/server/validation.rs | 5 +- 5 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/tests/component_projection.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index f5bc083..b76e01d 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -75,9 +75,19 @@ pub fn generate_component( .collect::>() .join("\n"); let mut tsx = format!( - "import {{ {} }} from \"@devup-ui/react\";\n\n", + "import {{ {} }} from \"@devup-ui/react\";\n", generated.imports.join(", ") ); + // Naming a component without importing it produces code that reads well and + // does not compile. When instances are left as references, whatever they + // refer to has to be resolvable, and the project convention is one named + // export per file under `@/components`. + for name in referenced_components(&generated.tsx) { + tsx.push_str(&format!( + "import {{ {name} }} from \"@/components/{name}\";\n" + )); + } + tsx.push('\n'); tsx.push_str(&format!( "export function {component_name}() {{\n return (\n{body}\n );\n}}\n" )); @@ -1498,3 +1508,30 @@ pub fn normalize_component_name(input: &str) -> String { } result } + +/// The custom components a rendered body refers to, in the order a reader meets +/// them, deduplicated. A devup-ui primitive is imported from the library and is +/// not one of these; anything else opening in PascalCase is. +fn referenced_components(body: &str) -> Vec { + const PRIMITIVES: [&str; 8] = [ + "Box", "Center", "Flex", "Grid", "Image", "Text", "VStack", "Input", + ]; + let mut seen = BTreeSet::new(); + let mut found = Vec::new(); + for (index, _) in body.match_indices('<') { + let rest = &body[index + 1..]; + let name = rest + .chars() + .take_while(|character| character.is_ascii_alphanumeric() || *character == '_') + .collect::(); + if name.is_empty() + || !name.starts_with(|character: char| character.is_ascii_uppercase()) + || PRIMITIVES.contains(&name.as_str()) + || !seen.insert(name.clone()) + { + continue; + } + found.push(name); + } + found +} diff --git a/crates/devup-mcp-devup-ui/tests/component_projection.rs b/crates/devup-mcp-devup-ui/tests/component_projection.rs new file mode 100644 index 0000000..5b10853 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/component_projection.rs @@ -0,0 +1,58 @@ +//! The same screen said twice, so a caller can place it in a project. +//! +//! `tsx` expands every instance into primitives: complete, but it cannot tell +//! you that a stretch of it is a Header the project may already own. +//! `componentTsx` keeps instances as `
` with the import that resolves +//! them. Neither alone is enough — one cannot be split, the other cannot be +//! rendered — and the difference between them is each component's body, which +//! is what a caller writes into a new file when the component is missing. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; + +fn capture(name: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens") + .join(name); + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + serde_json::from_value(value.get("snapshot").cloned().unwrap_or(value)).ok() +} + +fn render(snapshot: &Snapshot, root: &str, inline: bool) -> String { + let options = CodegenOptions { + inline_instances: inline, + ..CodegenOptions::default() + }; + generate_component(snapshot, root, &options) + .expect("the capture converts") + .tsx +} + +#[test] +fn an_instance_is_a_reference_in_one_projection_and_its_parts_in_the_other() { + let Some(snapshot) = capture("first-form.json") else { + eprintln!("no capture; skipping"); + return; + }; + let root = snapshot.roots.first().expect("a captured root"); + + let expanded = render(&snapshot, root, true); + let referenced = render(&snapshot, root, false); + + assert!( + referenced.contains("
"), + "componentTsx should name the instance: {referenced}" + ); + assert!( + !expanded.contains("
"), + "tsx should have expanded it instead: {expanded}" + ); + assert!( + referenced.contains("from '@/components/Header'") + || referenced.contains("from \"@/components/Header\""), + "a named component needs the import that resolves it: {referenced}" + ); +} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index f251ff0..4bdafbe 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -596,7 +596,7 @@ impl DevupServer { } #[tool( - description = "Acquire a Figma design once and project tsx/devupJson/sourceMap/rawSnapshot together in one collection; the primary Figma-to-code entry point, preferred over devup_figma_to_ui for implementation", + description = "Acquire a Figma design once and project tsx/componentTsx/devupJson/sourceMap/rawSnapshot together in one collection; the primary Figma-to-code entry point, preferred over devup_figma_to_ui for implementation. Request tsx and componentTsx together to get the same screen twice: tsx expands every instance into primitives, componentTsx keeps them as references with their imports, so the difference between them is each component's body", output_schema = permissive_object_output_schema() )] async fn devup_figma_export( diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 7cef241..6b82ac0 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -36,6 +36,13 @@ pub(super) fn projected_outputs_from_result( tsx.as_bytes().to_vec(), )); } + if let Some(tsx) = result.get("componentTsx").and_then(Value::as_str) { + outputs.push(ProjectedOutput::text( + "componentTsx", + "text/typescript", + tsx.as_bytes().to_vec(), + )); + } if let Some(devup_json) = result.get("devupJson").and_then(Value::as_str) { outputs.push(ProjectedOutput::text( "devupJson", @@ -848,6 +855,7 @@ pub(super) async fn complete_operation( section_tsx_projected = true; } + let component_name_for_components = component_name.clone(); if outputs.iter().any(|output| output == "tsx") && !section_tsx_projected { let node_id = payload.target.node_id.as_deref().ok_or_else(|| { DevupError::new( @@ -883,6 +891,33 @@ pub(super) async fn complete_operation( } } + if outputs.iter().any(|output| output == "componentTsx") { + let node_id = payload.target.node_id.as_deref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "A component TSX export payload requires a node ID.", + false, + ) + })?; + let output = generate_component( + &payload.snapshot, + node_id, + &CodegenOptions { + component_name: component_name_for_components, + include_diagnostics: false, + inline_instances: false, + root_layout, + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + )?; + if output_paths.contains_key("componentTsx") { + pending_text_outputs.insert("componentTsx".to_owned(), output.tsx.clone()); + } + result.insert("componentTsx".to_owned(), json!(output.tsx)); + result.insert("componentImports".to_owned(), json!(output.imports)); + } + if outputs.iter().any(|output| output == "devupJson") { let variables = payload.variables.as_ref().ok_or_else(|| { DevupError::new( diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index 981e455..d6f803b 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -15,8 +15,9 @@ use super::{ /// The JSON schema for `outputs` advertises this same constant, so a caller /// can discover the set instead of learning it one rejection at a time, and /// the published schema cannot drift from what is actually accepted. -pub(crate) const EXPORT_OUTPUTS: [&str; 6] = [ +pub(crate) const EXPORT_OUTPUTS: [&str; 7] = [ "tsx", + "componentTsx", "devupJson", "rawSnapshot", "sourceMap", @@ -35,7 +36,7 @@ pub(super) fn validate_artifact_projection( let design_output_requested = outputs.iter().any(|output| { matches!( output.as_str(), - "tsx" | "rawSnapshot" | "sourceMap" | "assetManifest" | "referencePng" + "tsx" | "componentTsx" | "rawSnapshot" | "sourceMap" | "assetManifest" | "referencePng" ) }); let theme_requested = outputs.iter().any(|output| output == "devupJson"); From c41385ba9b4cff963f37ed9a0e8d202b1646e58d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 19:54:38 +0900 Subject: [PATCH 65/69] feat(figma): collect a screen's other widths with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screen drawn at three widths is three sibling frames in a Section, named for the width they are. Converting one of them describes that width and calls it the screen, when what the caller asked for changes as it narrows — and the answer to how it changes is sitting next to the target, uncollected. When the target is itself named for a breakpoint and its parent is a Section, its similarly named siblings are collected with it. The snapshot script already takes a list of roots, which is how allScreens carries several screens at once, so this decides what goes in the list rather than adding machinery. It also already reads node.parent, so no extra Figma call is spent asking. Narrow on purpose. The target must be named for a breakpoint, and only siblings that are: a Section is equally how a file of unrelated cases is grouped, and pulling in every neighbour there would collect a catalogue in order to convert one square. Also brings the coverage checker back in step with the converter. An out-of-flow node holding children takes its height from what it holds, and `codegen::layout` drops it for that reason; the checker excused it only when padding had been derived, so a header pinned across the top of a screen was reported as unaccounted-for height that the reference implementation does not state either. Assets keep their height: those are drawn at a size and say so. --- crates/devup-mcp-devup-ui/src/provenance.rs | 16 +++++++++++ .../src/scripts/fast_snapshot.js | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 4422a86..0b3e143 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -525,6 +525,22 @@ fn layout_field_is_semantic( { return false; } + // An out-of-flow node that holds something takes its height from what it + // holds, and `codegen::layout` drops it for exactly that reason. Counting + // it here reported a shortfall against a value the converter is right not + // to state: a header pinned across the top of a screen came back as + // unaccounted-for height, and the reference implementation does not state + // it either. + if field == "height" + && view.string("layoutPositioning") == Some("ABSOLUTE") + && view.child_ids().next().is_some() + // Unless it folds into an asset, which is drawn at a size and says + // so — `codegen::layout` states the height there and drops it only + // for the node that holds live children. + && !projects_as_asset(snapshot, node) + { + return false; + } match field { "layoutMode" => matches!(view.string(field), Some("HORIZONTAL" | "VERTICAL" | "GRID")), "layoutPositioning" => view.string(field) == Some("ABSOLUTE"), diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 1bc41e6..74d4d40 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -7,6 +7,33 @@ if (roots.some((root) => !root)) throw new Error("DEVUP_NODE_NOT_FOUND"); if (roots.length === 1 && roots[0].type === "SECTION") { throw new Error("DEVUP_TARGET_IS_SECTION"); } + +// A screen drawn at three widths is three sibling frames in a Section, named +// for the width they are. Converting one of them alone can only describe that +// width, and the caller wanted the screen — so when the target is one of those +// frames, its siblings come along and the conversion can say how the screen +// changes rather than how it looks at one size. +// +// Narrow on purpose: the target must itself be named for a breakpoint, and only +// siblings that are. A Section is also how a file of unrelated cases is grouped, +// and pulling every neighbour in there would collect a catalogue to convert one +// square. +const BREAKPOINT_NAMES = ["mobile", "tablet", "desktop"]; +const breakpointRank = (node) => + BREAKPOINT_NAMES.indexOf(String(node.name || "").trim().toLowerCase()); +if (roots.length === 1 && breakpointRank(roots[0]) >= 0) { + const parent = roots[0].parent; + if (parent && parent.type === "SECTION" && "children" in parent) { + const family = parent.children + .filter((child) => child.id === roots[0].id || breakpointRank(child) >= 0) + .filter((child) => child.visible !== false) + .sort((left, right) => breakpointRank(left) - breakpointRank(right)); + if (family.length > 1) { + roots.length = 0; + roots.push(...family); + } + } +} const envelopeRootId = "__DEVUP_NODE_ID__"; const manifest = "__DEVUP_PLUGIN_API_MANIFEST__"; From 0473a1131213f5efeff43b310904792aa6eab5e2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 20:29:03 +0900 Subject: [PATCH 66/69] docs: write down how the plugin merges breakpoints, from its own output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was asked for one frame — the desktop width of a notice screen — and answered with four outputs: the frame as primitives, the frame with its instances left as references, the definitions of those components, and all three widths merged. Together they settle the questions this repo was about to answer by guessing, so they are written down before they are lost. Chief among them: component definitions cannot be recovered by diffing the first two outputs, which is how this was going to avoid emitting them. A definition carries the variant union its call site never mentions — `'scroll' | 'transparent' | 'mobileTranspa' | 'mobileScroll'` for a screen using one of the four — along with hover and active blocks and per-variant prop maps. Diffing gives a body and nothing else. Also recorded: the five-slot array and why the reference shows three, the split between subtrees that merge into responsive values and subtrees that are kept whole and toggled with display (with the capture showing why the banner cannot merge), and the one place the reference is not ground truth — component props do not go responsive, which its own author reads as an omission. --- docs/responsive-merge-rules.md | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/responsive-merge-rules.md diff --git a/docs/responsive-merge-rules.md b/docs/responsive-merge-rules.md new file mode 100644 index 0000000..acd64c5 --- /dev/null +++ b/docs/responsive-merge-rules.md @@ -0,0 +1,98 @@ +# Breakpoint merging, as the plugin does it + +Measured against `devup-Test` node `422:6865` (`desktop`), whose parent is the +`notice` Section holding `desktop` / `tablet` / `mobile`. The plugin was asked +for that one frame and answered with four outputs; what follows is what they +establish. Every claim here is read off those four, not inferred. + +## The four outputs + +| Output | What it is | +|---|---| +| Pure Code | the selected frame, primitives only, every instance expanded | +| desktop | the same frame with instances left as `
`, `` | +| desktop - Components | the definitions of those components, with their prop types | +| notice - Responsive | all three widths merged, components kept | + +Only the fourth carries `display` arrays. The first three describe one width. + +## Definitions cannot be derived + +The difference between Pure Code and the component-applied output gives a +component's *body*, so it looked as though definitions did not need their own +output. They do: + +```tsx +export interface HeaderProps { + property1: 'scroll' | 'transparent' | 'mobileTranspa' | 'mobileScroll' +} +``` + +That union comes from the component set's variants. Three of those four +variants appear nowhere in a screen that uses `property1="transparent"`, so no +amount of diffing recovers them. The same holds for `FooterProps`, and for +`Icons`, whose union names fifty-odd glyphs whose call site mentions one. + +A definition also carries what a call site cannot: `_hover` / `_active` / +`_selected` blocks, and per-variant prop maps written as +`bg={{ scroll: "$headerBg", mobileScroll: "$headerBg" }[property1]}`. + +## The array + +Five slots, `[mobile, null, tablet, null, PC]`. With two widths it is +`[mobile, null, null, null, PC]` — already how `Expression::Responsive` +renders. What appears in the reference is three slots, because this design's +tablet and desktop agree on every value that differs from mobile, so slot 2 +covers tablet upward and slots 3 and 4 are dropped rather than written null. + +```tsx +display={["none", null, "flex"]} // absent on mobile, present from tablet up +display={[null, null, "none"]} // present on mobile, absent from tablet up +``` + +## Two ways a subtree can differ + +**Structure matches → merge, and let differing values become arrays.** The +`Header` instance is identical across all three widths, so it appears once and +is not toggled at all: + +```tsx + +
+ +``` + +**Structure differs → keep both, toggle with `display`.** The banner is not one +node with responsive values; it is two nodes, each shown at its own widths. The +capture says why — the same-named frame is shaped differently: + +``` +mobile 'main banner' kids=3 [Frame…289, Logo, Logo] +desktop 'main banner' kids=2 [Frame…289, Frame…364] +``` + +The mobile banner also holds two absolutely-placed logos the desktop one does +not, and its text sits in a `pos="absolute"` stack rather than a centred +column. There is no alignment to merge, so both survive. + +The same split appears again in the content section: desktop puts the tabs +beside the search box in a `Flex`, mobile stacks them in a `VStack` with the +search box first, and both are emitted with opposite `display` arrays. + +## What the reference does not do + +Component props are not responsive. `
` stays +`desktop` at every width even though `FooterProps` admits `'mobile'` and +`'tablet'` and the definition lays out all three. Passing an array there does +nothing, and the design owner reads this as the plugin's omission rather than +intended behaviour — worth knowing before treating it as ground truth. + +## Where this lands in the code + +`variant.rs` already merges trees for viewport *variants* of a component set: +`same_rendered_structure` decides whether two trees are the same shape, +`merged_props` folds differing values into an expression, `unrepresented` +collects what could not be represented, and `Expression::Responsive` renders +the five-slot array. What is missing is the other entry: the same machinery +driven by sibling frames in a Section rather than by variants of a set, and a +third slot in the array. From fa84e158cc2793ad5e35211f5e9442b36a9ef325 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 20:32:40 +0900 Subject: [PATCH 67/69] docs: say which branch of the merge is the point and which is the cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two ways a subtree can differ were written down as though they were equal paths to choose between. They are not. A screen drawn at three widths is meant to be the same tree three times, so merging values into arrays is the whole idea, and keeping both copies behind a display toggle is what rescues an export when the file drifted. The screen says so itself. Three of its four children match in shape across all three widths and merge; the fourth is a banner whose two logos are wrapped in a frame on desktop and left loose on mobile — one intent grouped two ways. That is a slip in the design, not something the screen means to express, and it shows in the output as a desktop wrapper folded into a mask beside two separately placed mobile logos. --- docs/responsive-merge-rules.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/responsive-merge-rules.md b/docs/responsive-merge-rules.md index acd64c5..dcbeaec 100644 --- a/docs/responsive-merge-rules.md +++ b/docs/responsive-merge-rules.md @@ -52,6 +52,18 @@ display={[null, null, "none"]} // present on mobile, absent from tablet up ## Two ways a subtree can differ +These are not two equal paths. A screen drawn at three widths is meant to be +the same tree three times, and merging into arrays is what should happen; the +other branch is what saves an export when the design drifted. Of this screen's +four children, three merge and one does not: + +``` +[0] main banner mobile 3 children / tablet 2 / desktop 2 ← the odd one +[1] Header 1 / 1 / 1 +[2] section 1 / 1 / 1 +[3] Footer 1 / 1 / 1 +``` + **Structure matches → merge, and let differing values become arrays.** The `Header` instance is identical across all three widths, so it appears once and is not toggled at all: @@ -71,6 +83,14 @@ mobile 'main banner' kids=3 [Frame…289, Logo, Logo] desktop 'main banner' kids=2 [Frame…289, Frame…364] ``` +The two logos are wrapped in a frame on desktop and left loose on mobile — the +same intent grouped two ways, which is a drift in the file rather than a +difference the screen means to express. It shows in the output: the desktop +wrapper folds into `maskImage="url('/icons/Frame 1000014364.svg')"` while +mobile emits two separately placed logos. Read this branch as the cost of that +drift, not as the feature. A design whose widths agree in shape never reaches +it. + The mobile banner also holds two absolutely-placed logos the desktop one does not, and its text sits in a `pos="absolute"` stack rather than a centred column. There is no alignment to merge, so both survive. From cedd03d5763f1ec6112a12e2d654c51f3a954c23 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 20:38:46 +0900 Subject: [PATCH 68/69] feat(devup-ui): line up the widths a screen is drawn at, and say where they part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A responsive screen arrives as sibling frames named for their widths, and merging their differing values into arrays is only possible where the trees agree in shape. This finds out: it pairs the roots up narrowest-first, walks them together, and names every place they stop agreeing. Reporting is the point rather than a by-product. Widths of one screen are meant to be the same tree three times, so a place where they are not is usually a slip in the file — this screen's banner wraps its two logos in a frame on desktop and leaves them loose on mobile. The export can only carry that by keeping both copies and showing each at its own widths, which looks like success and hides the thing worth fixing unless it is said out loud. Instances are not walked into. A component drawn for several widths carries its own variant for each — the header is `transparent` on desktop and `mobileTranspa` on mobile — so its insides differ by design, and the reference keeps one `
` rather than merging what is behind it. Descending here reported six differences that were components doing their job, which is how this rule was found rather than assumed. Nor does it walk below a shape that already parted company: every descendant would be named for the same reason, burying the one place to look at. Against the notice screen this leaves four, in the two regions the reference keeps twice — the banner, and three shapes inside the content section — and nothing from the header or footer. --- crates/devup-mcp-devup-ui/src/codegen/mod.rs | 1 + .../src/codegen/responsive.rs | 178 ++++++++++++++++++ .../tests/responsive_alignment.rs | 91 +++++++++ 3 files changed, 270 insertions(+) create mode 100644 crates/devup-mcp-devup-ui/src/codegen/responsive.rs create mode 100644 crates/devup-mcp-devup-ui/tests/responsive_alignment.rs diff --git a/crates/devup-mcp-devup-ui/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index 5acabb8..f9cfbd1 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/mod.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/mod.rs @@ -1,6 +1,7 @@ mod compat; mod component; mod layout; +pub mod responsive; mod style; mod text; mod variant; diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs new file mode 100644 index 0000000..66ba4bb --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -0,0 +1,178 @@ +//! Lining up the same screen drawn at several widths. +//! +//! A responsive screen is three sibling frames in a Section, named for the +//! width they are, and the conversion wants them as one tree whose differing +//! values became arrays. That is only possible where the trees agree in shape, +//! and this module is the part that finds out: it pairs the roots up by name, +//! walks them together, and names every place they part company. +//! +//! Shape divergence is not the interesting case — it is the cost of one. Widths +//! of the same screen are meant to be the same tree three times, so a place +//! where they are not is usually a slip in the file, and the export can only +//! carry it by keeping both copies and showing each at its own widths. Saying +//! where that happened is the point of reporting it: silently keeping both +//! looks like success and hides the thing worth fixing. + +use devup_mcp_figma::{RawNode, Snapshot}; + +/// The widths a screen may be drawn at, narrowest first — the order devup-ui's +/// responsive arrays are written in. +pub const BREAKPOINT_NAMES: [&str; 3] = ["mobile", "tablet", "desktop"]; + +/// One width of a screen: which breakpoint it is, and the node it starts at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Breakpoint { + /// Index into [`BREAKPOINT_NAMES`], so narrowest sorts first. + pub rank: usize, + pub node_id: String, +} + +/// A place where the widths stopped agreeing, and what to say about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Divergence { + /// The node in the widest breakpoint that has no counterpart in shape. + pub node_id: String, + /// How to reach it from the root, so a reader can find the same place in + /// each width rather than only in the one being reported. + pub path: Vec, + pub reason: DivergenceReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DivergenceReason { + /// The node exists at one width and not another. + Missing, + /// Both exist and hold a different number of children. + ChildCount, + /// Both exist and are different kinds of node. + NodeType, +} + +impl DivergenceReason { + pub fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing at another width", + Self::ChildCount => "a different number of children", + Self::NodeType => "a different kind of node", + } + } +} + +fn rank_of(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + BREAKPOINT_NAMES.iter().position(|known| *known == name) +} + +/// The breakpoint roots this snapshot carries, narrowest first. +/// +/// Empty unless there are at least two: one width is a screen, not a screen +/// that changes, and there is nothing to line up. +pub fn breakpoints(snapshot: &Snapshot) -> Vec { + let mut found = snapshot + .roots + .iter() + .filter_map(|id| { + let node = snapshot.nodes.get(id)?; + let rank = rank_of(node.typed_view().name()?)?; + Some(Breakpoint { + rank, + node_id: id.clone(), + }) + }) + .collect::>(); + found.sort_by_key(|breakpoint| breakpoint.rank); + found.dedup_by_key(|breakpoint| breakpoint.rank); + if found.len() < 2 { + return Vec::new(); + } + found +} + +fn child_ids(snapshot: &Snapshot, node_id: &str) -> Vec { + snapshot + .nodes + .get(node_id) + .map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn node_at<'a>(snapshot: &'a Snapshot, root: &str, path: &[usize]) -> Option<&'a RawNode> { + let mut current = root.to_owned(); + for step in path { + current = child_ids(snapshot, ¤t).into_iter().nth(*step)?; + } + snapshot.nodes.get(¤t) +} + +/// Every place the widths stop agreeing in shape, in the order a reader meets +/// them. An empty result means the trees line up and their differing values can +/// become arrays. +pub fn divergences(snapshot: &Snapshot, breakpoints: &[Breakpoint]) -> Vec { + let Some(widest) = breakpoints.last() else { + return Vec::new(); + }; + let mut found = Vec::new(); + walk(snapshot, breakpoints, widest, &mut Vec::new(), &mut found); + found +} + +fn walk( + snapshot: &Snapshot, + breakpoints: &[Breakpoint], + widest: &Breakpoint, + path: &mut Vec, + found: &mut Vec, +) { + let Some(reference) = node_at(snapshot, &widest.node_id, path) else { + return; + }; + let reference_children = child_ids(snapshot, &reference.id).len(); + + for breakpoint in breakpoints { + if breakpoint.rank == widest.rank { + continue; + } + let reason = match node_at(snapshot, &breakpoint.node_id, path) { + None => Some(DivergenceReason::Missing), + Some(other) if other.node_type != reference.node_type => { + Some(DivergenceReason::NodeType) + } + Some(other) if child_ids(snapshot, &other.id).len() != reference_children => { + Some(DivergenceReason::ChildCount) + } + Some(_) => None, + }; + if let Some(reason) = reason { + found.push(Divergence { + node_id: reference.id.clone(), + path: path.clone(), + reason, + }); + // Below a shape that already parted company there is nothing to + // compare: every descendant would be reported for the same reason, + // burying the one place worth looking at. + return; + } + } + + // An instance is not descended into. A component drawn for several widths + // carries its own variant for each — a header is `transparent` on desktop + // and `mobileTranspa` on mobile — so its insides differ by design, and the + // reference keeps one `
` rather than merging what is behind it. + // Walking in here reported six shape differences that are the component + // doing its job. + if reference.node_type == "INSTANCE" { + return; + } + + for index in 0..reference_children { + path.push(index); + walk(snapshot, breakpoints, widest, path, found); + path.pop(); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_alignment.rs b/crates/devup-mcp-devup-ui/tests/responsive_alignment.rs new file mode 100644 index 0000000..363821e --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_alignment.rs @@ -0,0 +1,91 @@ +//! The same screen at three widths, lined up. + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::responsive::{DivergenceReason, breakpoints, divergences}; +use devup_mcp_figma::Snapshot; + +fn capture(name: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens") + .join(name); + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + serde_json::from_value(value.get("snapshot").cloned().unwrap_or(value)).ok() +} + +/// One width is a screen, not a screen that changes. +#[test] +fn a_single_width_has_nothing_to_line_up() { + let Some(snapshot) = capture("bp-desktop.json") else { + eprintln!("no capture; skipping"); + return; + }; + assert!(breakpoints(&snapshot).is_empty()); +} + +/// Narrowest first, because that is the order the arrays are written in. +#[test] +fn widths_are_ordered_the_way_the_array_is() { + let Some(snapshot) = capture("bp-family.json") else { + eprintln!("no capture; skipping"); + return; + }; + let found = breakpoints(&snapshot); + let names = found + .iter() + .map(|breakpoint| { + snapshot.nodes[&breakpoint.node_id] + .typed_view() + .name() + .unwrap_or_default() + }) + .collect::>(); + assert_eq!(names, ["mobile", "tablet", "desktop"]); +} + +/// The reference keeps two of this screen's four children twice — the banner +/// and the content section — each shown at its own widths, and merges the rest. +/// Those are the places the widths part company, and they are what shows up +/// here: the banner at the top level, and three shapes inside the section. +/// +/// Nothing from the Header or Footer appears. Both are instances, both hold a +/// different variant per width, and both are meant to: descending into them +/// reported six differences that were the components doing their job. +#[test] +fn the_places_the_widths_part_company_are_named_and_no_others() { + let Some(snapshot) = capture("bp-family.json") else { + eprintln!("no capture; skipping"); + return; + }; + let found = divergences(&snapshot, &breakpoints(&snapshot)); + let name_of = |id: &String| { + snapshot.nodes[id] + .typed_view() + .name() + .unwrap_or_default() + .to_owned() + }; + + assert_eq!(found.len(), 4, "{found:?}"); + + let banner = &found[0]; + assert_eq!(banner.path, vec![0]); + assert_eq!(banner.reason, DivergenceReason::ChildCount); + assert_eq!(name_of(&banner.node_id), "main banner"); + + // The other three sit under the section, which is the second region the + // reference keeps twice. + assert!( + found[1..].iter().all(|divergence| divergence.path[0] == 2), + "{found:?}" + ); + + // The header is child 1 and the footer child 3; neither is walked into. + assert!( + !found + .iter() + .any(|divergence| matches!(divergence.path.first(), Some(1) | Some(3))), + "an instance was descended into: {found:?}" + ); +} From 1c58653c3a8b0eaeac47074988dfe6de4eaf23b4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 4 Sep 2026 20:56:28 +0900 Subject: [PATCH 69/69] docs: keep what the plugin answered, and say it is not the same as right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was asked for one frame — the desktop width of a notice screen — and returned four things. Two are kept here: the merged responsive output, which is the only account of how breakpoints are supposed to come together, and the same frame with its instances left as references. They existed nowhere but a chat window, and the work that needs them has not started yet. Named for what they are. Calling this a reference invites the next reader to chase parity with it line for line, and its own author does not vouch for every line: `
` stays desktop at every width though the component admits mobile and tablet, which he reads as the plugin never having implemented responsive component props. The README says so, next to the one other place the evidence itself invites doubt — a banner kept twice because the design grouped its logos two ways, which a design whose widths agree in shape would never produce. Where these and the pinned corpus say the same thing, that is two independent accounts and the bar to differ is high. Where they disagree, it is a question, and the answer belongs in writing. --- fixtures/plugin-answers/README.md | 47 ++++ fixtures/plugin-answers/responsive.tsx | 246 ++++++++++++++++++++ fixtures/plugin-answers/with-components.tsx | 104 +++++++++ 3 files changed, 397 insertions(+) create mode 100644 fixtures/plugin-answers/README.md create mode 100644 fixtures/plugin-answers/responsive.tsx create mode 100644 fixtures/plugin-answers/with-components.tsx diff --git a/fixtures/plugin-answers/README.md b/fixtures/plugin-answers/README.md new file mode 100644 index 0000000..8d8dfed --- /dev/null +++ b/fixtures/plugin-answers/README.md @@ -0,0 +1,47 @@ +# What the plugin answered + +Not "the correct output". These are the four things `devup-figma-plugin` returned +when asked for one frame — the `desktop` width (`422:6865`) of the `notice` +screen in `devup-Test` — and they are kept because they are the only account of +how it behaves on a real screen, not because they are known to be right. + +The distinction matters. The plugin is the reference this repo aims to match or +beat, and its author does not vouch for every line here. Treat a difference as a +question, the same way a difference against the pinned corpus is a question, and +say which way it was settled. + +## The files + +| File | The plugin's tab | What it shows | +|---|---|---| +| `pure.tsx` | Pure Code | the frame with every instance expanded to primitives | +| `with-components.tsx` | desktop | the same frame with instances left as `
` | +| `components.tsx` | desktop - Components | the definitions of those components | +| `responsive.tsx` | notice - Responsive | all three widths merged | + +Only `responsive.tsx` carries `display` arrays. The other three describe one +width. + +## Known doubtful, by the author + +- **`
` in `responsive.tsx`.** It stays `desktop` at + every width, though `FooterProps` admits `'mobile'` and `'tablet'` and + `components.tsx` lays out all three. Component props do not go responsive — + passing an array there does nothing — and the author reads this as the plugin + not having implemented it rather than as intended. Do not match this. + +## Doubtful on the evidence + +- **The banner is kept twice.** Its shape differs between widths because two + logos are wrapped in a frame on desktop and left loose on mobile — one intent + grouped two ways, which is drift in the design file. The plugin's answer is + reasonable given that input, but a design whose widths agree in shape should + never produce it, so this is not a pattern to reproduce for its own sake. See + `docs/responsive-merge-rules.md`. + +## Not doubted + +Everything measured against the pinned corpus agreed with what this repo emits: +angles, mask positions, image folders, border shorthand order, omitted canvas +sizes, blend flattening. Where these files and the corpus say the same thing, +that is two independent accounts, and the bar to differ from them is high. diff --git a/fixtures/plugin-answers/responsive.tsx b/fixtures/plugin-answers/responsive.tsx new file mode 100644 index 0000000..b0afc01 --- /dev/null +++ b/fixtures/plugin-answers/responsive.tsx @@ -0,0 +1,246 @@ +import { Box, Center, Flex, Image, Text, VStack } from '@devup-ui/react' +import { Footer } from '@/components/Footer' +import { Header } from '@/components/Header' +import { Pagination } from '@/components/Pagination' +import { Tab } from '@/components/Tab' + +export default function NoticePage() { + return ( + + + + + + Notice + + + + + 공지사항 + + + + + + + + + Notice + + + + + 공지사항 + + + + + + + + + + +
+ + + + + + + + + + + + + + 라멘집 + + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + +
+
+ + + + + + 라멘집 + + + + + + + + + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + + + +
+
+
+ + ) +} diff --git a/fixtures/plugin-answers/with-components.tsx b/fixtures/plugin-answers/with-components.tsx new file mode 100644 index 0000000..ca50e13 --- /dev/null +++ b/fixtures/plugin-answers/with-components.tsx @@ -0,0 +1,104 @@ + + + + + + Notice + + + + + 공지사항 + + + + + +
+ + + + + + + + + + + + + + 라멘집 + + {/* */} + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + +
+
+
+