diff --git a/.changepacks/changepack_log_direct_oauth_path.json b/.changepacks/changepack_log_direct_oauth_path.json new file mode 100644 index 0000000..f971ed7 --- /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. 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/.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/.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 ff3157f..56cb121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,89 @@ 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: + # 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 + 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: @@ -27,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/.gitignore b/.gitignore index 0743a8d..33364db 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,12 @@ .env.* !.env.example +# Agent session state (oh-my-claudecode / omo), local to a working copy +.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/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/README.md b/README.md index 98e3040..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은 필요하지 않습니다. ## 빌드와 설치 @@ -83,25 +82,36 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. { "status": "disconnected", "paths": { - "direct": { "available": false, "reason": "저장된 자격증명 없음. ..." }, - "localDevMode": { "endpoint": "http://127.0.0.1:3845/mcp", "reachable": false, "hint": "..." }, - "hostHandoff": { "expectedTool": "use_figma", "note": "..." } + "direct": { + "available": false, + "credentialSource": "none", + "tokenState": "absent", + "callbackPort": { "port": null, "free": null }, + "reason": "저장된 자격증명 없음. ..." + }, }, - "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`로 포함됩니다. 자세한 제약과 3가지 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. +`doctor`는 네트워크 호출을 전혀 하지 않습니다. `paths.direct.credentialSource`는 `cli-arg`, `env`, `credential-store`, `none` 중 하나이고, `tokenState`는 `valid`, `expired`, `absent` 중 하나이며, `callbackPort`는 `--figma-callback-port`를 지정했을 때만 실측한 `port`/`free`를 담습니다. 자세한 제약과 두 연결 경로는 아래 "Figma 연결 설정" 절을 참고하세요. -## Figma 연결 설정 +### direct 경로에 사전 등록된 client 자격증명 주입하기 + +Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급받은 client)의 `client_id`/`client_secret`을 이미 가지고 있다면, devup-mcp에 다음 세 가지 방법 중 하나로 주입해 Dynamic Client Registration을 완전히 건너뛸 수 있습니다. 우선순위는 시작 인자 > 환경변수 > `configure`로 저장한 값입니다. -devup-mcp가 Figma에 붙는 경로는 세 가지입니다. +- **시작 인자**: `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(시작 인자/환경변수와는 별도 항목)에 저장되어 프로세스를 재시작해도 유지됩니다. -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 경로입니다. +자격증명이 해석되면 `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_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 없이 바로 쓸 수 있다"는 안내는 확신에 차서 틀린 안내였고, 믿은 쪽이 한 턴을 버린 뒤에야 알게 됩니다. ### 원격 OAuth 등록 제약 (실측) @@ -132,6 +142,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`을 직접 지정합니다. @@ -237,6 +249,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 @@ -268,9 +282,9 @@ Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 ca 탐색과 검색은 변수 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 전체와 실제 사용 리소스를 수집합니다. 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 수만 포함되며 원본 디자인이나 인증 정보는 포함되지 않습니다. 완전성 등급은 다음과 같습니다. @@ -281,7 +295,7 @@ Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 ca ## 읽기 전용·개인정보 보호 - 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을 기본적으로 디스크에 저장하지 않습니다. @@ -315,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-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/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/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index 1384c6f..b76e01d 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, ) })?; @@ -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" )); @@ -98,7 +108,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 +189,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 +239,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 +292,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 +330,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 +351,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 +441,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 +463,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 +973,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 +1056,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, )); } @@ -1188,10 +1198,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 @@ -1420,22 +1436,23 @@ 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, ), ( 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로 변환되지 않을 수 있습니다.", + "Some Figma effects may not be converted into computed CSS.", FidelityImpact::Lossy, ), ]; @@ -1491,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/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index f194575..7ed9d6b 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"); @@ -74,6 +77,23 @@ 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 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); + } } else if is_page_root { // Figma page roots define the component canvas; their editor dimensions // are not emitted as runtime constraints. @@ -240,13 +260,18 @@ pub(super) fn push_layout_props( string_prop(props, "flex", "1"); } - push_auto_layout(node, component, props); - push_padding(node, props); + push_auto_layout(snapshot, node, component, props); + push_padding(snapshot, node, 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") @@ -292,6 +317,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; } @@ -338,7 +374,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; @@ -401,8 +437,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) @@ -413,13 +457,86 @@ 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. +/// 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(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. + 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")?); + 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 = derived_padding(snapshot, node); 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)] = [ @@ -433,20 +550,34 @@ fn push_padding(node: &RawNode, props: &mut Vec) { if top == 0.0 && right == 0.0 && bottom == 0.0 && left == 0.0 { return; } - if top == right && right == bottom && bottom == left { - string_prop(props, "p", px(top)); + // 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)); + } + }; + // 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 { - string_prop(props, "py", px(top)); + if same(top, bottom) { + 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)); + if same(left, right) { + 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/src/codegen/mod.rs b/crates/devup-mcp-devup-ui/src/codegen/mod.rs index 60336a5..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; @@ -15,3 +16,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/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/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index d78ee11..cc41107 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -9,136 +9,180 @@ 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) +} + +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 - }); - } - 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 - }); - } + return Some(svg_asset_kind(snapshot, node)); } - 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") - ) + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + return leaf_asset_kind(snapshot, 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; } - 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 } } @@ -154,7 +198,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); @@ -383,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 { @@ -431,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)?; @@ -439,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")) @@ -474,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 { @@ -954,6 +1047,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/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-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/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/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 248cc9f..0b3e143 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, derived_padding}; 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 @@ -285,7 +296,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, @@ -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, @@ -461,8 +500,45 @@ 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; + } + // 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; + } + // 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 { @@ -481,7 +557,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) @@ -494,84 +580,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/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/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/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..a250654 --- /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, + "Failed to parse devup.json as 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..9ab3f2a --- /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 failed TypeScript+JSX syntax validation: {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} is not defined in 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} uses hardcoded color {text}. Consider using a devup.json token." + ), + 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} uses hardcoded length {text}. Consider using a devup.json token." + ), + 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} is not a prop recognized by {}.", + 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}: ... }}) accepts only statically analyzable literal values. Variables or expressions break zero-runtime extraction." + ), + 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-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/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs new file mode 100644 index 0000000..727fd62 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -0,0 +1,344 @@ +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_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 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 + // 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( + "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": {} + } + ]), + ); + + // 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("` 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-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-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); +} 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/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}" + ); +} 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}" + ); +} 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")); +} 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-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" + ); + } +} 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}" + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/provenance.rs b/crates/devup-mcp-devup-ui/tests/provenance.rs index e3b94da..61b3fd9 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("(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 + ); +} 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:?}" + ); +} 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-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-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 1242885..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,23 +19,20 @@ export function Wquw151Frame387936059() { h="66px" justifyContent="space-between" overflow="hidden" - px="0px" py="8px" w="360px" > -
- -
-
+ +
-
- -
-
+ +
) -> 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/testcase_expectations.rs b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs new file mode 100644 index 0000000..1559009 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/testcase_expectations.rs @@ -0,0 +1,257 @@ +//! 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. +//! +//! 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. +//! +//! 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. + +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, +} + +/// 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(); + 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; 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, Option)> = 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() + .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((at, normalise(&text))), + None => { + // A case is sometimes wrapped in a frame that only positions it + // 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, root.clone(), lone_child)); + } + } + } + + // 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. + // + // 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. + // 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, 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((!same_kind, distance, note, case)); + } + } + pairs.sort_by(|left, right| { + left.0 + .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 { + if spoken_for_note[note] || spoken_for_case[case] { + continue; + } + spoken_for_note[note] = true; + spoken_for_case[case] = true; + 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 +} + +#[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.node_id.clone(), + 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 (node_id, expected, actual) in entries { + eprintln!(" node : {node_id}"); + eprintln!(" states : {expected}"); + eprintln!(" we emit: {actual}\n"); + } + } +} 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-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/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 5171401..0af1f30 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -1,9 +1,9 @@ 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}; +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,19 +138,229 @@ 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, + ), + }; + + // 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(), + field, + source_kind, + image_hash, + format: None, + scale: None, + status, + byte_length: None, + sha256: None, + mime_type: None, + data_base64: None, + output_path: None, + error_code, + } +} + pub fn validate_asset_requests( snapshot: &Snapshot, 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 +368,21 @@ 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.")); + } + // 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(()) @@ -197,7 +393,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 +405,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 +434,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 +445,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() { @@ -275,18 +471,48 @@ 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 응답에 요청한 binary가 없습니다."))?; - let bytes = STANDARD - .decode(data.as_bytes()) - .map_err(|_| invalid("asset export binary의 base64가 올바르지 않습니다."))?; + 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, + 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(), + "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()) || 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 { @@ -341,29 +567,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/collector.rs b/crates/devup-mcp-figma/src/collector.rs index d7962d6..9782e33 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; -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; @@ -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)] @@ -147,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, @@ -219,7 +232,15 @@ 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, completed: bool, } @@ -254,7 +275,11 @@ 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, completed: false, } @@ -266,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() @@ -276,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), @@ -298,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( @@ -320,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), @@ -397,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( @@ -413,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, @@ -437,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(), @@ -463,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(), @@ -479,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; } @@ -515,6 +545,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) @@ -524,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 => { @@ -561,18 +592,37 @@ 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 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 is missing."))?; + 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 .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); @@ -581,14 +631,39 @@ 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); } + 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 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 is missing."))?; + 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 @@ -601,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))?; @@ -643,7 +718,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 +727,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; @@ -693,22 +768,71 @@ impl CollectorSession { .target .node_id .clone() - .ok_or_else(|| invalid_call("Figma fast snapshot에는 node ID가 필요합니다."))?; - self.metadata = Some(json!({ - "transport": "png-envelope-v1", - "rootId": root_id, - "nodeCount": payload.snapshot.nodes.len() - })); - self.root_node_id = Some(root_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(); - self.stats.transport = "png-envelope-v1".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 { + offset: 0, + 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(()) } @@ -726,7 +850,11 @@ 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(); self.assets_scheduled = self.request.asset_selections.is_empty(); self.reference_png = None; @@ -762,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, )); } @@ -776,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(); @@ -807,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, )); } @@ -838,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()])?; @@ -858,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(); @@ -893,15 +1020,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(()) @@ -912,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(); @@ -939,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()); @@ -947,15 +1076,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(()) @@ -973,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, @@ -990,7 +1119,7 @@ impl CollectorSession { { return Err(DevupError::new( ErrorCode::DevupFigmaVersionChanged, - "multi-root 수집 중 Figma 파일 버전이 변경되었습니다.", + "The Figma file version changed during multi-root collection.", true, )); } @@ -1000,7 +1129,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" } @@ -1029,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 { @@ -1080,19 +1205,29 @@ 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가 모두 포함되지 않았습니다.", + "Section snapshot does not include all selected roots.", )); } 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, }]) @@ -1110,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( @@ -1141,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 { @@ -1154,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); @@ -1164,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()); @@ -1173,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( @@ -1211,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, @@ -1235,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), @@ -1257,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, @@ -1282,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)?; @@ -1352,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), @@ -1379,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); @@ -1420,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, )); } @@ -1447,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, ) })?; @@ -1537,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, )); } @@ -1560,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( @@ -1598,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( @@ -1651,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( @@ -1680,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()), @@ -1713,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; @@ -1725,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( @@ -1773,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) @@ -1808,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, ) })?; @@ -1820,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, )); } @@ -1834,61 +1972,22 @@ 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.") } } -#[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 { @@ -1904,6 +2003,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, @@ -1949,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 @@ -1972,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, )); } @@ -2006,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( @@ -2028,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()), @@ -2073,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, )); } @@ -2086,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/credentials.rs b/crates/devup-mcp-figma/src/credentials.rs index 826b45c..b361901 100644 --- a/crates/devup-mcp-figma/src/credentials.rs +++ b/crates/devup-mcp-figma/src/credentials.rs @@ -4,12 +4,25 @@ 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")] 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,114 @@ 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, ) } + +/// 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, + "Cannot read the stored Figma client credentials. Run configure again.", + 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/envelope.rs b/crates/devup-mcp-figma/src/envelope.rs index 144da20..1040955 100644 --- a/crates/devup-mcp-figma/src/envelope.rs +++ b/crates/devup-mcp-figma/src/envelope.rs @@ -1,27 +1,25 @@ 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::{ DevupError, ErrorCode, FigmaTarget, ResourceKind, SnapshotChunk, UpstreamResult, - collect_used_resource_refs, + collect_used_resource_refs, read_snapshot_cursor, }; -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_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; -const MAX_ENVELOPE_CHUNKS: usize = 32; +/// 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; -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 +42,8 @@ pub struct FastThemePayload { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Envelope { + #[serde(default)] + kind: Option, schema_version: u32, source: EnvelopeSource, snapshot: SnapshotChunk, @@ -58,31 +58,24 @@ 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)] -#[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 { + #[serde(default)] + kind: Option, schema_version: u32, source: ThemeEnvelopeSource, resources: Value, @@ -96,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 { @@ -103,20 +97,6 @@ struct ThemeEnvelopeIntegrity { variable_count: usize, style_count: usize, unresolved_count: usize, - 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( @@ -143,84 +123,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)?; - 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)?; - if envelope_bytes.len() > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); - } - 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, - target, - expected_root_ids, - envelope_bytes.len(), - )?; - + let Some((envelope, utf8_bytes)) = + find_tagged_text::(&raw, "devupFastSnapshotEnvelope")? + else { + return Err(invalid("textEnvelopeMissing")); + }; + let page = peek_page_cursor(&envelope.snapshot)?; + validate_envelope(&envelope, target, expected_root_ids, page)?; Ok(FastSnapshotPayload { snapshot: envelope.snapshot, resources: UpstreamResult { raw: envelope.resources, }, stats: FastTransportStats { - 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, }, }) } @@ -230,75 +162,52 @@ pub fn decode_fast_theme( expected_file_key: &str, ) -> Result { let raw = normalize_upstream_result(&result.raw)?; - 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)?; - if envelope_bytes.len() > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); - } - 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, - expected_file_key, - envelope_bytes.len(), - )?; + let Some((envelope, utf8_bytes)) = + find_tagged_text::(&raw, "devupFastThemeEnvelope")? + else { + return Err(invalid("textEnvelopeMissing")); + }; + validate_theme_envelope(&envelope, expected_file_key)?; Ok(FastThemePayload { resources: UpstreamResult { raw: envelope.resources, }, source_version: envelope.source.version, stats: FastTransportStats { - 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 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(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). + None => Ok(PageCursor { + is_first_page: true, + is_final_page: true, + }), + } +} + fn normalize_upstream_result(value: &Value) -> Result, DevupError> { match value { Value::String(text) => { @@ -313,227 +222,65 @@ fn normalize_upstream_result(value: &Value) -> Result, DevupError } } -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) { +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(Value::String(text)) = object.get("text") - && let Ok(descriptor) = serde_json::from_str::(text) - && descriptor.kind == "devupFastSnapshotDescriptor" + 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(descriptor); + found.push(text); } for child in object.values() { - collect(child, found); + collect(child, expected_kind, found); } } Value::Array(values) => { for child in values { - collect(child, found); + collect(child, expected_kind, found); } } - _ => {} + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} } } - 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")); + 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")); } - } 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>) -> 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 > MAX_ENVELOPE_BYTES { - return Err(too_large("envelope")); + serde_json::from_str(text) + .map(|envelope| Some((envelope, text.len()))) + .map_err(|_| invalid("envelopeJson")) } + _ => Err(invalid("textEnvelopeMultiplicity")), } - 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: &EnvelopeDescriptor, target: &FigmaTarget, expected_root_ids: &[String], - utf8_bytes: usize, + page: PageCursor, ) -> Result<(), DevupError> { - if envelope.schema_version != 1 || descriptor.schema_version != 1 { + if envelope.schema_version != 1 + || envelope + .kind + .as_deref() + .is_some_and(|kind| kind != "devupFastSnapshotEnvelope") + { return Err(invalid("schemaVersion")); } let target_root = target @@ -543,42 +290,44 @@ 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 || envelope.snapshot.root_ids != expected_root_ids { return Err(invalid("targetMismatch")); } - if envelope.integrity.utf8_bytes != utf8_bytes || descriptor.utf8_bytes != utf8_bytes { - return Err(invalid("utf8Bytes")); - } - let mut node_ids = BTreeSet::new(); for node in &envelope.snapshot.nodes { if !node_ids.insert(node.id.as_str()) { 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.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.variable_ref_count != refs.variable_ids.len() || envelope.integrity.style_ref_count != refs.styles.len() - || descriptor.style_ref_count != refs.styles.len() { return Err(invalid("resourceRefCount")); } @@ -588,19 +337,19 @@ fn validate_envelope( fn validate_theme_envelope( envelope: &ThemeEnvelope, - descriptor: &ThemeEnvelopeDescriptor, expected_file_key: &str, - utf8_bytes: usize, ) -> Result<(), DevupError> { - if envelope.schema_version != 1 || descriptor.schema_version != 1 { + if envelope.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 { - return Err(invalid("utf8Bytes")); - } let resources = envelope .resources .as_object() @@ -614,25 +363,17 @@ fn validate_theme_envelope( .ok_or_else(|| invalid("unresolvedShape"))?; validate_theme_count( envelope.integrity.collection_count, - descriptor.collection_count, collections.len(), "collectionCount", )?; validate_theme_count( envelope.integrity.variable_count, - descriptor.variable_count, variables.len(), "variableCount", )?; - validate_theme_count( - envelope.integrity.style_count, - descriptor.style_count, - styles.len(), - "styleCount", - )?; + validate_theme_count(envelope.integrity.style_count, styles.len(), "styleCount")?; validate_theme_count( envelope.integrity.unresolved_count, - descriptor.unresolved_count, unresolved.len(), "unresolvedCount", )?; @@ -657,11 +398,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(()) @@ -734,21 +474,10 @@ 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, - "Figma fast snapshot envelope 검증에 실패했습니다.", + "Figma fast snapshot envelope validation failed.", false, json!({"category": category}), ) @@ -757,7 +486,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/errors.rs b/crates/devup-mcp-figma/src/errors.rs index a681a60..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, @@ -21,6 +22,8 @@ pub enum ErrorCode { DevupCodegenFailed, DevupThemeConflict, DevupCompatCorpusDrift, + DevupInvalidInput, + DevupProjectRootNotFound, } #[derive(Clone, Serialize, Deserialize)] 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/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 3c0ab8f..72cd33d 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -18,11 +18,13 @@ mod variables; pub use collector::{ CollectedParts, CollectionRequest, CollectionScope, CollectionStats, CollectorSession, - CollectorStep, PlannedCall, ReferencePng, SectionReadOptions, + CollectorStep, PlannedCall, ReferencePng, ScreenFailure, SectionReadOptions, }; 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, DEFAULT_CLIENT_NAME, DirectPathSnapshot, + OAuthManager, SecretString, SystemBrowser, TokenState, +}; pub use payload::{ CollectedPayload, PayloadCompleteness, PayloadCompletenessReport, PayloadStructure, ResourceAudit, validate_payload_context, @@ -54,12 +59,12 @@ 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, - 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/metadata.rs b/crates/devup-mcp-figma/src/metadata.rs index 5072ffb..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, - "Figma MCP 응답에서 metadata를 찾지 못했습니다.", + "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), @@ -122,13 +160,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, @@ -260,3 +318,63 @@ fn find_metadata(value: &Value) -> Option { _ => None, } } + +#[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()); + } +} diff --git a/crates/devup-mcp-figma/src/oauth.rs b/crates/devup-mcp-figma/src/oauth.rs index 171f33a..2a678c8 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,72 @@ 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, + /// 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>; } @@ -32,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, ) })?; @@ -40,10 +113,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 +138,16 @@ 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, + /// 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 { @@ -76,6 +163,10 @@ impl OAuthManager { store, client, callback_timeout: Duration::from_secs(180), + callback_port: None, + static_client_credentials: None, + client_credential_store: Arc::new(MemoryClientCredentialStore::default()), + client_name: DEFAULT_CLIENT_NAME.to_owned(), } } @@ -84,6 +175,60 @@ 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 + } + + /// 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. + 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 +241,128 @@ 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, + client_name: self.client_name.clone(), + }) + } + 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 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), + None => { + let response = self + .client + .post(&metadata.registration_endpoint) + .json(&serde_json::json!({ + "client_name": self.client_name.as_str(), + "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, + registration.client_secret.map(SecretString), + ) + } + }; let state = random_urlsafe(32); let verifier = random_urlsafe(64); @@ -138,7 +372,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 +382,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 +407,8 @@ impl OAuthManager { .map_err(auth_network_error)?; let authorization = StoredAuthorization { - client_id: registration.client_id, + client_id, + client_secret, access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token @@ -202,15 +441,28 @@ 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()), + ]; + // 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 .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)? @@ -312,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)] @@ -336,7 +598,7 @@ async fn receive_callback( .map_err(|_| { DevupError::new( ErrorCode::DevupAuthCallbackTimeout, - "Figma 인증 응답 시간이 초과되었습니다.", + "Figma authentication response timed out.", true, ) })? @@ -364,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, )); } @@ -378,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{}", @@ -393,6 +655,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!( + "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 }), + ) +} + fn protected_resource_url(endpoint: &Url) -> Url { let mut url = endpoint.clone(); url.set_query(None); @@ -438,7 +740,7 @@ fn now() -> u64 { fn auth_required() -> DevupError { DevupError::new( ErrorCode::DevupAuthRequired, - "Figma 인증이 필요합니다.", + "Figma authentication is required.", false, ) } @@ -446,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/src/payload.rs b/crates/devup-mcp-figma/src/payload.rs index f7ba2a4..85208d6 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, }) } } @@ -145,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/plugin_api_manifest.json b/crates/devup-mcp-figma/src/plugin_api_manifest.json index 0bbbd62..838d436 100644 --- a/crates/devup-mcp-figma/src/plugin_api_manifest.json +++ b/crates/devup-mcp-figma/src/plugin_api_manifest.json @@ -1,25 +1,19 @@ [ - "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" + "arcData", "backgroundStyleId", "blendMode", + "bottomLeftRadius", "bottomRightRadius", "boundVariables", "characters", "clipsContent", + "componentProperties", "componentPropertyDefinitions", "componentPropertyReferences", "constraints", "cornerRadius", + "counterAxisAlignItems", "dashPattern", "defaultVariant", "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", "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" ] 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/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/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 23f8752..74d4d40 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -1,36 +1,135 @@ -"__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"); +} + +// 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__"; -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_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; -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); - } - for (const name of manifest) { - try { - if (name in value) names.add(name); - } catch (_) {} - } - return [...names].sort(); +// 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", + "fillStyleId", + "gridStyleId", + "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 !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 SCALAR_DEFAULTS.has(name) && SCALAR_DEFAULTS.get(name) === value; } -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() }; @@ -44,20 +143,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); @@ -66,30 +181,71 @@ 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) : []; + 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. + // 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) : []; + if (childrenIds.length > 0) fields.childrenIds = childrenIds; - for (const name of propertyNames(node)) { - if (skipped.has(name) || name.startsWith("_")) continue; + // 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); - (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)); + // 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 = []; @@ -102,7 +258,27 @@ 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)); +} function styleTypeForField(field) { if (field === "textStyleId") return "TEXT"; @@ -112,11 +288,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; @@ -140,174 +314,84 @@ function scanResources(value, fieldName = "") { ) { if (!styleTypes.has(child)) styleTypes.set(child, styleType); } - scanResources(child, field || fieldName); + scanResources(child, variableIds, styleTypes); } } -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 = { - 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, @@ -317,96 +401,105 @@ const envelope = { localComplete: false, usedRemoteComplete: unresolved.length === 0, unresolved, - }, - 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; -} -envelopeBytes = utf8Encode(JSON.stringify(envelope)); -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"); + $variableRefCount: sortedVariableIds.length, + $styleRefCount: sortedStyles.length, + }; } -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)); - } +// 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 (crc ^ 0xffffffff) >>> 0; + return pageNodes; } -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; +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; } - return output; -} - -function pngChunk(type, data) { - const typeBytes = ascii(type); - return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]); + if (envelope.integrity.utf8Bytes !== utf8ByteLength(JSON.stringify(envelope))) { + throw new Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE"); + } + return { envelope, bytes }; } -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); +// 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 { - kind: "devupFastSnapshotDescriptor", - schemaVersion: 1, - rootId: envelopeRootId, - nodeCount: nodes.length, - variableRefCount: sortedVariableIds.length, - styleRefCount: sortedStyles.length, - utf8Bytes: envelopeBytes.length, - chunkCount, -}; +if (!built) throw new Error("DEVUP_ENVELOPE_TOO_LARGE"); +return built.envelope; diff --git a/crates/devup-mcp-figma/src/scripts/fast_theme.js b/crates/devup-mcp-figma/src/scripts/fast_theme.js index 68c0d03..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,5 @@ 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 +236,7 @@ function utf8Encode(value) { } const envelope = { + kind: "devupFastThemeEnvelope", schemaVersion: 1, source: { fileKey: figma.fileKey || "", version: null }, resources: { @@ -269,55 +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"); - -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/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index f81e09e..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,6 +98,24 @@ for (let index = 0; index < queue.length && traversalCount < MAX_TRAVERSED_NODES } if ("children" in node) queue.push(...node.children); } +// 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 }); + } +} candidateNodes.sort((left, right) => left.box.y - right.box.y || left.box.x - right.box.x diff --git a/crates/devup-mcp-figma/src/scripts/snapshot.js b/crates/devup-mcp-figma/src/scripts/snapshot.js index bd76931..30c657a 100644 --- a/crates/devup-mcp-figma/src/scripts/snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/snapshot.js @@ -160,6 +160,21 @@ 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. + // 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)) { @@ -211,7 +226,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/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..55d396f 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(); @@ -161,6 +159,34 @@ 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. 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)) + .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()) @@ -247,7 +273,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 +294,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 90445a3..a6899c5 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 response contains duplicate cursors.", + Self::Shape => "Figma snapshot cursor format is invalid.", + } + } + + 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, @@ -346,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, ) })?; @@ -354,14 +424,14 @@ 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 { 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, )); } @@ -371,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, - "동일한 Figma node에 서로 다른 snapshot 데이터가 반환되었습니다.", + "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 { @@ -399,7 +498,7 @@ pub fn snapshot_chunk_from_result(result: &UpstreamResult) -> Result 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, @@ -118,55 +86,58 @@ impl UpstreamFailureKind { let (code, message, retryable) = match self { Self::CatalogRejected => ( 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, ), }; - 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!([ + "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 }", + "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/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index ac7859e..f977490 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( @@ -452,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, } } @@ -601,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, @@ -610,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(), @@ -624,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() @@ -632,6 +639,7 @@ impl ReadToolCall { }), Self::PageCatalog { file_key } => json!({ "fileKey": file_key, + "description": self.description(), "code": BuiltinScript::PageCatalog.source("", ScriptInputs::default()) }), Self::ExploreSnapshot { @@ -640,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() @@ -648,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() @@ -664,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() @@ -673,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/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/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index 539705a..189bd09 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -4,9 +4,10 @@ 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 _; fn node(id: &str, node_type: &str, fields: serde_json::Value) -> RawNode { RawNode { @@ -24,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, }]; @@ -54,7 +55,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":[] }), }, @@ -67,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" @@ -96,29 +97,105 @@ 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(), 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(), @@ -131,19 +208,205 @@ 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 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(), + 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-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index b480a90..c0034f4 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-envelope-v1"); + 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-envelope-v1"); + 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() } @@ -641,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" }] }), } @@ -659,7 +600,7 @@ fn file_page_metadata() -> UpstreamResult { { "id": "0:1", "type": "PAGE", - "name": "표지", + "name": "Cover", "childrenIds": ["1:2"], "descendantCount": 1 }, @@ -740,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 }]); @@ -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-theme-envelope-v1"); + 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); @@ -1407,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": [] }), }, @@ -1430,8 +1373,8 @@ 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": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": {"nextOffset": 2, "complete": true, "totalNodes": 2}, "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": [] }), }, @@ -1474,13 +1417,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 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") }; - 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")); + 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] @@ -1821,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": { @@ -1855,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 b8e97b4..0507f0d 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,36 +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); -} - -#[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()); + assert_eq!(decoded.stats.wire_bytes, envelope.len()); + assert_eq!(decoded.stats.chunk_count, 0); + assert_eq!(decoded.stats.transport, "text"); } #[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] @@ -71,17 +60,62 @@ 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_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)); + }); + 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 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"); +} + +#[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() @@ -105,139 +139,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", ); @@ -250,45 +165,169 @@ 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", + ); +} + +/// `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] +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 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| { + 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"); @@ -307,26 +346,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 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); } @@ -340,6 +377,7 @@ fn target() -> FigmaTarget { fn complete_envelope() -> Vec { finalize_envelope(json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, "source": { "fileKey": "fileKey123", @@ -365,7 +403,7 @@ fn complete_envelope() -> Vec { "type": "TEXT", "fields": { "textStyleId": "S:style1", - "characters": "테스트" + "characters": "Test" } } ], @@ -390,7 +428,12 @@ 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"}, "resources": { @@ -411,38 +454,73 @@ fn theme_envelope() -> Vec { "unresolvedCount": 0, "utf8Bytes": 0 } - })) + }) } -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 - }); +fn text_upstream_result(envelope: &[u8]) -> UpstreamResult { UpstreamResult { raw: json!({ - "content": [ - {"type": "text", "text": descriptor.to_string()}, - {"type": "image", "data": STANDARD.encode(png), "mimeType": "image/png"} - ] + "content": [{ + "type": "text", + "text": std::str::from_utf8(envelope).unwrap() + }] }), } } +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 { let mut value: Value = serde_json::from_slice(&complete_envelope()).unwrap(); mutate(&mut value); 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; @@ -458,158 +536,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/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/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/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." + ); +} diff --git a/crates/devup-mcp-figma/tests/oauth_flow.rs b/crates/devup-mcp-figma/tests/oauth_flow.rs index 522f007..017dcdf 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, + DEFAULT_CLIENT_NAME, DirectPathSnapshot, ErrorCode, MemoryClientCredentialStore, + MemoryCredentialStore, OAuthManager, SecretString, TokenState, }; use serde_json::{Value, json}; use tokio::{net::TcpListener, sync::Mutex}; @@ -121,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] @@ -164,3 +167,420 @@ async fn logout_clears_persisted_authorization() -> anyhow::Result<()> { assert_eq!(manager.status().await?, AuthStatus::Disconnected); 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, +) -> (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: 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 +/// 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"); + // 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() + .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")); + + // 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"], 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(()) +} + +/// 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), + 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-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-figma/tests/section.rs b/crates/devup-mcp-figma/tests/section.rs index c4b4a1a..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,22 +44,102 @@ 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(()) } +#[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:?}" + ); + // 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(()) +} + #[test] fn selection_and_batches_are_strict_bounded_and_deterministic() -> anyhow::Result<()> { let target = 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"] @@ -79,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( 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-figma/tests/upstream_contract.rs b/crates/devup-mcp-figma/tests/upstream_contract.rs index c7b1665..64b8567 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] @@ -188,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, @@ -199,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(")); } @@ -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,44 @@ 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)")); + // 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", @@ -335,34 +385,64 @@ fn fast_snapshot_is_lossless_bounded_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")); - 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")); - assert!(code.contains("MAX_ENVELOPE_BYTES")); - assert!(code.contains("0xfffd")); - assert!(!code.contains("maxPayloadBytes")); - assert!(!code.contains("maxFieldBytes")); + // 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")); + 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__")); + // 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. + 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("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] -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\"]")); + // `__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" ); } @@ -407,10 +487,16 @@ 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")); assert!(!code.contains("eval(")); assert!(!code.contains("Function(")); for mutation in [ 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/Cargo.toml b/crates/devup-mcp/Cargo.toml index 121b5af..f3aa203 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -26,4 +26,11 @@ 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 +# `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/lib.rs b/crates/devup-mcp/src/lib.rs index 0c90557..3d8911f 100644 --- a/crates/devup-mcp/src/lib.rs +++ b/crates/devup-mcp/src/lib.rs @@ -4,9 +4,99 @@ 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, + /// 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 +/// (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, + /// `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 +/// (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, + 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 { + return FigmaDirectConfig { + client_id: Some(client_id), + 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`/ +/// `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)] @@ -38,26 +128,88 @@ 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; + 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_client_name.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") => { 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(flag) => anyhow::bail!("지원하지 않는 devup-mcp 인자입니다: {flag}"), - None => anyhow::bail!("devup-mcp 인자는 UTF-8 flag여야 합니다."), + Some("--figma-client-id") => { + let value = arguments + .next() + .ok_or_else(|| anyhow::anyhow!("--figma-client-id requires a value."))?; + let value = value + .to_str() + .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 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 requires a value."))?; + let value = value + .to_str() + .ok_or_else(|| { + anyhow::anyhow!("--figma-client-secret must be a UTF-8 string.") + })? + .to_owned(); + if value.is_empty() { + 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 requires a port number.") + })?; + let value = value.to_str().ok_or_else(|| { + anyhow::anyhow!("--figma-callback-port must be a UTF-8 string.") + })?; + figma_callback_port = Some(value.parse::().map_err(|_| { + anyhow::anyhow!("--figma-callback-port must be a number between 1 and 65535.") + })?); + } + Some(flag) => anyhow::bail!("Unsupported devup-mcp argument: {flag}"), + None => anyhow::bail!("devup-mcp arguments must be UTF-8 flags."), } } if roots.is_empty() { @@ -65,14 +217,28 @@ where } Ok(CliAction::Serve(ServerConfig { allowed_write_roots: roots, + 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_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_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 +264,20 @@ 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_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)? + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await?; service.waiting().await?; Ok(()) } 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/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index a5c6b4b..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 three connection paths (direct OAuth, -//! local Dev Mode MCP, 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). @@ -22,162 +14,132 @@ //! 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; +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}가 응답하고 있습니다. 호스트에 이 로컬 Dev Mode MCP가 등록되어 있다면 OAuth 없이 그 도구를 바로 사용할 수 있습니다." - ) - } else { - format!( - "{LOCAL_DEV_MODE_ENDPOINT}가 응답하지 않습니다. Figma 데스크톱 앱 → Preferences → Dev Mode MCP 서버를 켜면 OAuth 없이 사용할 수 있습니다 (Dev 또는 Full 시트가 있는 유료 플랜 필요)." - ) - } -} - -/// 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. -/// -/// 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. -pub async fn host_requirement() -> Value { - let reachable = local_dev_mode_reachable().await; - json!({ - "reason": "devup-mcp는 Figma에 직접 접속하지 않습니다. 호스트에 등록된 공식 Figma MCP가 이 read-only 호출을 대신 실행해야 합니다.", - "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) 전까지 반복하세요." - ], - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "reachable": reachable, - "hint": local_dev_mode_hint(reachable) - }, - "ifUnavailable": { - "action": "stop-and-report", - "message": "Figma MCP에 접근할 수 없으면 즉시 멈추고 보고하세요. 디자인 수치를 추측해서 구현하지 마세요.", - "setupHint": "devup_figma_auth { action: \"doctor\" } 를 호출하면 사용 가능한 경로와 클라이언트별 설정 방법을 얻을 수 있습니다." - } - }) -} - /// 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 /// the constraint and points at the public waitlist. -pub async fn doctor_report(status: AuthStatus) -> Value { - let reachable = local_dev_mode_reachable().await; +/// +/// `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 direct_available = status == AuthStatus::Connected; json!({ "status": status, "paths": { "direct": { "available": direct_available, - "reason": if direct_available { - "저장된 자격증명이 있습니다." - } else { - "저장된 자격증명 없음. Figma는 allowlist된 client_name으로 등록한 client에만 Dynamic Client Registration을 허용합니다." - } - }, - "localDevMode": { - "endpoint": LOCAL_DEV_MODE_ENDPOINT, - "reachable": reachable, - "hint": "Figma 데스크톱 → Preferences → Dev Mode MCP 서버 활성화 (Dev/Full 시트 필요)" - }, - "hostHandoff": { - "expectedTool": "use_figma", - "note": "devup-mcp 내부에서는 확인 불가합니다. 호스트가 공식 Figma MCP를 노출해야 합니다." + "credentialSource": direct.credential_source, + "tokenState": direct.token_state, + "callbackPort": { + "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) } }, "clientSetup": client_setup() }) } +/// `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 "A stored credential is present."; + } + match credential_source { + ClientCredentialSource::None => { + "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/)." + } + ClientCredentialSource::CliArg + | ClientCredentialSource::Env + | ClientCredentialSource::CredentialStore => { + "A pre-registered client credential is present. Authenticate with devup_figma_auth \ + { action: \"login\" } to use the direct path." + } + } +} + 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." }, - "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" + "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" + }, + "otherHosts": { + "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.", + "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 시트가 있는 유료 플랜이 필요합니다." } }) } @@ -186,53 +148,123 @@ fn client_setup() -> Value { mod tests { use super::*; + fn absent_direct_snapshot() -> DirectPathSnapshot { + DirectPathSnapshot { + credential_source: ClientCredentialSource::None, + token_state: devup_mcp_figma::TokenState::Absent, + callback_port: None, + callback_port_free: None, + client_name: DEFAULT_CLIENT_NAME.to_owned(), + } + } + + #[tokio::test] + async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { + 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, absent_direct_snapshot()).await; + assert_eq!(disconnected["status"], "disconnected"); + assert_eq!(disconnected["paths"]["direct"]["available"], false); + assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); + 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 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); + 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 the reference for installing elsewhere. + 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 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); + 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] - async fn host_requirement_always_instructs_stop_and_report_when_unavailable() { - let value = host_requirement().await; - assert_eq!(value["ifUnavailable"]["action"], "stop-and-report"); + 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), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }; + 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!( - !value["ifUnavailable"]["message"] + !report["paths"]["direct"]["reason"] .as_str() .unwrap() - .is_empty() + .contains("waitlist") ); - assert!(value["steps"].as_array().unwrap().len() >= 4); - assert!(value["localDevMode"]["reachable"].is_boolean()); } + /// `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_reflects_measured_auth_status_without_changing_status_shape() { - let connected = doctor_report(AuthStatus::Connected).await; - assert_eq!(connected["status"], "connected"); - assert_eq!(connected["paths"]["direct"]["available"], true); - - let disconnected = doctor_report(AuthStatus::Disconnected).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" - ); - assert!(disconnected["clientSetup"]["constraints"]["clientNameAllowlist"].is_string()); - assert!(disconnected["clientSetup"]["opencode"]["example"].is_object()); + 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), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }; + 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/handoff.rs b/crates/devup-mcp/src/server/handoff.rs deleted file mode 100644 index 1cc8b95..0000000 --- a/crates/devup-mcp/src/server/handoff.rs +++ /dev/null @@ -1,455 +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, -} - -#[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; - -#[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( - "동시에 유지할 수 있는 Figma handoff session 수를 초과했습니다.", - )); - } - 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()), - }; - 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 encoded_len = serde_json::to_vec(&result) - .map_err(|_| invalid("Figma handoff result를 JSON으로 읽을 수 없습니다."))? - .len(); - if encoded_len > self.limits.max_result_bytes { - self.remove(session_id).await; - return Err(too_large( - "Figma handoff result의 허용 크기를 초과했습니다.", - )); - } - - 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 result의 전체 메모리 한도를 초과했습니다.", - )); - } - 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 reason = if session.consumed.contains(call_id) { - "consumed" - } else { - "unknown_call" - }; - put_session(&mut state, session_id.to_owned(), session); - return Err(invalid_reason( - "알 수 없거나 이미 처리한 Figma handoff call ID입니다.", - reason, - )); - }; - let collector_call_id = collector_call_id.clone(); - 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); - } - } -} - -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( - "존재하지 않는 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, - "Figma handoff session이 만료되었습니다.", - true, - json!({"source": "host", "reason": "expired"}), - ) -} - -fn too_large(message: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaResponseTooLarge, - message, - false, - json!({"source": "host"}), - ) -} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index cb498a0..4bdafbe 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1,11 +1,14 @@ pub mod artifacts; pub mod delivery; mod diagnostics; -pub mod handoff; +pub mod operation; pub mod output; +mod project_context; +mod project_root; mod projection; mod quality; pub mod resources; +mod stack_diff; mod tools; mod validation; @@ -26,17 +29,18 @@ 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, - KeyringCredentialStore, OAuthManager, RemoteFigmaClient, ResourceScope, SearchReadOptions, - SectionCandidate, SectionIndex, SectionReadOptions, SourcePolicy, SystemBrowser, - fallback_allowed_for_error, + AuthStatus, ClientCredentialSource, ClientCredentials, CollectedParts, CollectedPayload, + CollectionRequest, CollectionScope, CollectorSession, CollectorStep, CredentialStore, + DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, + ExploreNode, ExploreReadOptions, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, + KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, + SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, + 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::{ @@ -45,8 +49,8 @@ use validation::{ }; pub use tools::{ - AuthInput, ContinueInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, - FigmaSearchInput, FigmaToJsonInput, FigmaToUiInput, + AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput, + FigmaToJsonInput, FigmaToUiInput, ProjectContextInput, StackDiffInput, UiValidateInput, }; const FIGMA_ENDPOINT: &str = "https://mcp.figma.com/mcp"; @@ -56,6 +60,41 @@ 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, + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }) + } + + /// 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, + "This auth backend does not support configuring client credentials.", + false, + )) + } } #[async_trait] @@ -73,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)] @@ -86,8 +137,24 @@ 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_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 { + 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)) } @@ -97,7 +164,6 @@ impl Services { pub struct DevupServer { tool_router: ToolRouter, services: Services, - handoffs: HandoffStore, artifacts: ArtifactStore, output_policy: OutputPolicy, } @@ -118,22 +184,28 @@ impl DevupServer { Ok(Self { tool_router: Self::tool_router(), services, - handoffs: HandoffStore::default(), artifacts: ArtifactStore::default(), output_policy: OutputPolicy::from_roots(roots)?, }) } + 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())) } } @@ -170,18 +242,11 @@ 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, - "Figma direct 연결을 사용하려면 devup_figma_auth login이 필요합니다.", + "Using the Figma direct connection requires devup_figma_auth login.", false, json!({"source": "direct"}), )); @@ -205,21 +270,86 @@ impl DevupServer { ) .await } - Err(error) if fallback_allowed_for_error(policy, &error) => { - self.begin_handoff(operation, request, artifact_key).await - } Err(error) => Err(error), } } + /// 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 { - Ok(result) => collector.accept(&call_id, result)?, + 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 + // 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 collector has always done. + Ok(result) if operation::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); + } + } + // 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 operation::upstream_error(&result.raw) { + Some(error) => { + 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), } @@ -229,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, - "Figma handoff artifact key가 없습니다.", - 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 @@ -320,7 +382,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( @@ -329,7 +391,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 requires 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, @@ -338,7 +423,7 @@ impl DevupServer { _ => { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupAuthRequired, - "action은 status, login, logout 또는 doctor여야 합니다.", + "action must be status, login, logout, configure, or doctor.", false, ))); } @@ -348,7 +433,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( @@ -359,7 +444,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, )) })?; @@ -431,7 +516,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( @@ -465,7 +550,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( @@ -476,14 +561,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, ))); } @@ -511,31 +596,7 @@ impl DevupServer { } #[tool( - description = "Continue a read-only Figma host handoff with an official MCP result", - 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 multiple DevupUI artifacts", + 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( @@ -548,7 +609,7 @@ impl DevupServer { { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "assetRequests를 사용하려면 outputs에 assetManifest가 필요합니다.", + "Using assetRequests requires assetManifest in outputs.", false, ))); } @@ -556,7 +617,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, ))); } @@ -571,14 +632,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, )) })?; @@ -590,7 +651,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, )) })?; @@ -600,7 +661,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, ))); } @@ -674,7 +735,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, )) })?; @@ -682,7 +743,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, ))); } @@ -731,6 +792,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 { @@ -790,7 +908,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, )), } @@ -814,7 +932,19 @@ 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. 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 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.", + ) } async fn list_resources( 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/output.rs b/crates/devup-mcp/src/server/output.rs index 537c128..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)] @@ -82,19 +98,27 @@ 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}")))?; - opened.push(Arc::new(OutputRoot { dir, display_path })); + .map_err(|error| invalid_path(format!("Cannot open the output root: {error}")))?; + opened.push(Arc::new(OutputRoot { + dir, + display_path, + requested_path: root, + })); } Ok(Self { roots: Arc::new(opened), @@ -104,18 +128,22 @@ 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() { 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())) }) - .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 +174,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 +182,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 +193,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 +232,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 +250,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 +355,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 +419,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 +448,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 +492,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 +534,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 new file mode 100644 index 0000000..945ff1b --- /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, + "Could not determine the current directory.", + 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( + "No devup.json found. $token references cannot be verified, so the unknown-token check is skipped. Do not guess and use tokens that do not exist.", + vec![display_path(&root.join("devup.json"))], + )), + }); + }; + let source = std::fs::read_to_string(&file).map_err(|error| { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + "Could not read 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 must be theme, api, db, or 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, + "Could not determine the current directory.", + 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( + "No devup.json found. Do not write code by guessing color, typography, length, or shadow token names.", + 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( + "No openapi.json found. Do not write code by guessing API endpoint or schema names.", + 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( + "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) + )], + ); + } + 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..2d7c78c --- /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 = "No project root found. No directory containing devup.json, package.json, Cargo.toml, or .git was found. Do not write code by guessing token, endpoint, or column names."; + +/// 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/projection.rs b/crates/devup-mcp/src/server/projection.rs index 50c7330..6b82ac0 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::{ @@ -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", @@ -50,14 +57,14 @@ pub(super) fn projected_outputs_from_result( .ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "referencePng resource에 base64 data가 없습니다.", + "The referencePng resource has no base64 data.", false, ) })?; let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "referencePng resource의 base64가 올바르지 않습니다.", + "The referencePng resource base64 is invalid.", false, ) })?; @@ -101,7 +108,7 @@ fn encode_projected_json(value: &Value) -> 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 +137,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 +218,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 +241,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 +258,35 @@ fn projected_asset_outputs(manifest: &AssetManifest) -> Result>(); + 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() @@ -732,7 +769,7 @@ pub(super) async fn complete_operation( if requested.len() != frame_ids.len() { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "frameIds에 중복 node가 있습니다.", + "frameIds contains a duplicate node.", false, )); } @@ -743,14 +780,17 @@ pub(super) async fn complete_operation( return Err(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, format!( - "Section 내부 screen frame이 아니거나 존재하지 않습니다: {node_id}" + "Not a screen frame inside the Section, or it does not exist: {node_id}" ), false, )); } 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()); @@ -815,11 +855,12 @@ 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( ErrorCode::DevupFigmaNodeNotFound, - "TSX export payload에는 node ID가 필요합니다.", + "A TSX export payload requires a node ID.", false, ) })?; @@ -850,11 +891,38 @@ 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( ErrorCode::DevupSnapshotUnsupported, - "Figma 변수/style 수집 결과가 없습니다.", + "There is no Figma variable/style collection result.", false, ) })?; @@ -883,7 +951,7 @@ pub(super) async fn complete_operation( let raw = serde_json::to_value(&payload.snapshot).map_err(|error| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - format!("raw snapshot을 직렬화할 수 없습니다: {error}"), + format!("Cannot serialize the raw snapshot: {error}"), false, ) })?; @@ -922,7 +990,7 @@ pub(super) async fn complete_operation( let reference = payload.reference_png.as_ref().ok_or_else(|| { DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, - "artifact에 요청한 reference PNG가 없습니다. URL로 다시 수집하세요.", + "The requested reference PNG is not in the artifact. Re-collect it from the URL.", false, ) })?; @@ -931,7 +999,7 @@ pub(super) async fn complete_operation( .map_err(|_| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "artifact reference PNG의 base64가 올바르지 않습니다.", + "The artifact reference PNG base64 is invalid.", false, ) })?; @@ -944,7 +1012,7 @@ pub(super) async fn complete_operation( { return Err(DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "artifact reference PNG의 길이 또는 hash가 일치하지 않습니다.", + "The artifact reference PNG length or hash does not match.", false, )); } @@ -980,7 +1048,7 @@ pub(super) async fn complete_operation( return Err(DevupError::new( ErrorCode::DevupFigmaHandoffInvalid, format!( - "artifact에 요청한 정확한 asset export가 없습니다. URL로 다시 수집하세요: {}", + "The exact requested asset export is not in the artifact. Re-collect it from the URL: {}", capture.asset_id ), false, @@ -1023,7 +1091,7 @@ pub(super) async fn complete_operation( return Err(DevupError::with_details( ErrorCode::DevupSnapshotUnsupported, format!( - "strict export는 exact/complete output만 허용합니다: status={}, quality={}", + "strict export only allows exact/complete output: status={}, quality={}", quality.status(), serde_json::to_string(&quality).unwrap_or_default() ), @@ -1035,8 +1103,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": "This tsx is the final deliverable. Implement from this value." + }), + ); + } let mut planned_outputs = Vec::new(); for (output, contents) in pending_text_outputs { if let Some(path) = output_paths.get(&output) { @@ -1063,14 +1150,14 @@ pub(super) async fn complete_operation( let data = asset.data_base64.as_deref().ok_or_else(|| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "export된 asset binary가 artifact에 없습니다.", + "The exported asset binary is not in the artifact.", false, ) })?; let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { DevupError::new( ErrorCode::DevupSnapshotUnsupported, - "export된 asset binary의 base64가 올바르지 않습니다.", + "The exported asset binary base64 is invalid.", false, ) })?; @@ -1126,7 +1213,7 @@ pub(super) async fn complete_operation( } PendingOperation::Collect | PendingOperation::Artifact { .. } => 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 new file mode 100644 index 0000000..0a19918 --- /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, + "Each layers entry must be one of 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, + "Could not determine the current directory.", + 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": "No models/ directory found (no Vespertide models).", + "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!( + "No sea-orm entity ({}) found for the {table} model. Check that `vespertide export --orm seaorm` was run.", + 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": "No models/ directory found (no Vespertide models).", + "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!( + "No route files under {}, so entity-route correspondence cannot be checked.", + 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!( + "No route referencing {table}.{column_name} was found. It may be an intentionally internal-only column." + ), + "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": "Found neither src/routes/ nor 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": "No src/routes/ found, so the code-side routes cannot be checked.", + "openapiSpecsFound": specs_checked, + "drifts": [], + }); + } + if openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "No openapi.json found, so there is no spec to compare against.", + "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": "A route present in the code is missing from openapi.json. The spec may be stale (rebuild needed).", + "routes": stale_spec, + "confidence": "medium", + })); + } + if !stale_code_or_merged.is_empty() { + drifts.push(json!({ + "kind": "openapi-path-not-found-in-scanned-routes", + "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", + })); + } + + 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": "No frontend .ts/.tsx files found.", + "drifts": [], + }); + } + if openapi_files.is_empty() { + return json!({ + "checked": false, + "reason": "No openapi.json found, so frontend calls cannot be verified.", + "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": "The endpoint/operationId the frontend calls was not found in 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..dee9d4d 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -1,16 +1,23 @@ use rmcp::schemars::JsonSchema; -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, 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)] @@ -57,6 +64,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, @@ -89,6 +100,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, @@ -96,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 { @@ -160,6 +137,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/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index 48eeb0d..d6f803b 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -10,6 +10,21 @@ 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; 7] = [ + "tsx", + "componentTsx", + "devupJson", + "rawSnapshot", + "sourceMap", + "assetManifest", + "referencePng", +]; + pub(super) fn validate_artifact_projection( artifact: &ArtifactLookup, outputs: &[String], @@ -21,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"); @@ -55,7 +70,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,18 +95,18 @@ 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, )); } 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!("지원하지 않는 export output입니다: {output}"), + format!( + "Unsupported export output: {output}. Supported: {}.", + EXPORT_OUTPUTS.join(", ") + ), false, )); } @@ -103,10 +118,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는 auto, direct 또는 host여야 합니다.", + ErrorCode::DevupInvalidInput, + "sourcePolicy must be auto or direct.", false, )), } @@ -124,7 +139,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 +154,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 +166,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 +190,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 +202,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/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/cli.rs b/crates/devup-mcp/tests/cli.rs index 615bbf7..6fa09cb 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,207 @@ 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); + assert_eq!(config.figma_client_name, 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()); +} + +/// 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* + // 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("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] +fn resolve_figma_direct_config_falls_back_to_env_then_to_none() { + let env_only = resolve_figma_direct_config( + 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, 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, 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/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index 2d24883..88110ab 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,12 +240,17 @@ 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); 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| { @@ -403,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?; @@ -446,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() @@ -518,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() @@ -624,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( @@ -756,31 +765,46 @@ 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": { "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": { @@ -802,7 +826,7 @@ fn fast_envelope_result(partial: bool, lossy: bool) -> UpstreamResult { "unresolved": [] }, "integrity": { - "nodeCount": 1, + "nodeCount": 2, "variableRefCount": 1, "styleRefCount": 0, "utf8Bytes": 0 @@ -826,36 +850,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()} ]}), } } @@ -883,24 +885,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/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 7b8b6c0..2942d08 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, DEFAULT_CLIENT_NAME, 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, @@ -98,29 +139,33 @@ 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"]; assert!(client_setup["constraints"]["clientNameAllowlist"].is_string()); 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["codex"].as_str().unwrap().contains("figma")); - assert_eq!( - client_setup["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" + assert!(client_setup["otherHosts"]["opencode"]["example"]["mcp"]["figma"]["oauth"].is_object()); + assert!( + client_setup["otherHosts"]["claudeCode"] + .as_str() + .unwrap() + .contains("figma") ); // No actual credential material, ever. `clientSetup` legitimately @@ -166,75 +211,195 @@ async fn doctor_action_reflects_connected_status_without_changing_the_status_act 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. +/// 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 needs_figma_always_carries_an_actionable_host_requirement() -> anyhow::Result<()> { - let result = call_named_tool( +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_to_ui", + "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), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }, + 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, + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }, + configured: Mutex::new(None), + }); + let result = call_named_tool( + auth.clone(), + Arc::new(UnavailableUpstream::default()), + "devup_figma_auth", json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "sourcePolicy": "auto" + "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")); - 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); + let captured = auth.configured.lock().await.clone(); assert_eq!( - host_requirement["ifUnavailable"]["action"], - "stop-and-report" - ); - assert!( - host_requirement["ifUnavailable"]["message"] - .as_str() - .unwrap() - .contains("추측") - ); - assert!( - host_requirement["ifUnavailable"]["setupHint"] - .as_str() - .unwrap() - .contains("doctor") - ); - assert!(host_requirement["localDevMode"]["reachable"].is_boolean()); - assert_eq!( - host_requirement["localDevMode"]["endpoint"], - "http://127.0.0.1:3845/mcp" + captured, + Some(( + "preregistered-client".to_owned(), + Some("preregistered-secret".to_owned()) + )) ); Ok(()) } #[tokio::test] -async fn host_policy_needs_figma_also_carries_the_host_requirement() -> anyhow::Result<()> { - let result = call_named_tool( +async fn configure_action_without_client_id_is_rejected() -> anyhow::Result<()> { + let error = call_named_tool( Arc::new(AuthProbe { - status: AuthStatus::Connected, + 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": "host" + "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?; - let output = result.structured_content.unwrap(); + .await + .expect_err("plain AuthProbe does not support configure"); + assert!(!error.to_string().is_empty()); + Ok(()) +} - assert_eq!(output["status"], "needs_figma"); - assert_eq!( - output["hostRequirement"]["ifUnavailable"]["action"], - "stop-and-report" - ); +/// 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(); + for (label, value) in [("doctor", &doctor)] { + 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(()) } diff --git a/crates/devup-mcp/tests/figma_explore.rs b/crates/devup-mcp/tests/figma_explore.rs index 4c69b0b..82cc59b 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": {} } ], @@ -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/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..dc168a5 --- /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("guessing") + ); + 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/handoff.rs b/crates/devup-mcp/tests/handoff.rs deleted file mode 100644 index 278dd9d..0000000 --- a/crates/devup-mcp/tests/handoff.rs +++ /dev/null @@ -1,430 +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 - }] - } - } - }) -} - -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); -} - -#[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 - ); -} - -#[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/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); 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/resource_delivery.rs b/crates/devup-mcp/tests/resource_delivery.rs index e4dfae5..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, @@ -479,5 +479,6 @@ fn payload() -> CollectedPayload { stats: CollectionStats::default(), assets: Vec::new(), reference_png: None, + failures: Vec::new(), } } diff --git a/crates/devup-mcp/tests/section_export.rs b/crates/devup-mcp/tests/section_export.rs index 7c1c78f..65e46e0 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, @@ -99,6 +98,18 @@ async fn section_requires_selection_then_exports_requested_or_all_screens_from_o .collect::>(), ["10:3", "10:2"] ); + assert_eq!( + selection["nextAction"]["why"], + "This link is a Section and holds several screens inside. Collecting them all at once exceeds the size limit." + ); + assert_eq!( + selection["nextAction"]["how"], + "Call again with the target screen's canonicalUrl from screens[], or use allScreens:true if you need every screen." + ); + assert_eq!( + selection["nextAction"]["doNot"], + "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(); assert_eq!(selection["cache"]["capabilities"]["kind"], "section-index"); @@ -137,8 +148,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 +170,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( @@ -185,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")) @@ -260,6 +340,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": { @@ -273,55 +354,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 aea317f..c7d4de7 100644 --- a/crates/devup-mcp/tests/source_orchestration.rs +++ b/crates/devup-mcp/tests/source_orchestration.rs @@ -275,48 +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"); - assert!( - output["calls"][0]["arguments"]["code"] - .as_str() - .unwrap() - .contains("devupFastSnapshotDescriptor") - ); - 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 { @@ -351,6 +309,14 @@ 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(()) } @@ -425,223 +391,57 @@ async fn direct_fast_call_error_restarts_the_legacy_collector() -> anyhow::Resul 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 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()); - assert_eq!(rate_limited.calls.load(Ordering::SeqCst), 1); - Ok(()) -} - -#[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); - assert_eq!( - after_fast["collection"]["fallbackReason"], - "descriptorMissing" + 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 28f51da..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(), - 7, - "expected all 7 devup_figma_* tools 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 4e051d6..93405e7 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 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 +// 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); @@ -144,12 +151,14 @@ 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", "devup_figma_to_json", "devup_figma_to_ui", + "devup_project_context", + "devup_stack_diff", + "devup_ui_validate", ] ); @@ -201,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(()) 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..7dd2d67 --- /dev/null +++ b/crates/devup-mcp/tests/upstream_error_surfacing.rs @@ -0,0 +1,185 @@ +//! 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 + }), + }) + } +} + +/// 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<()> +{ + 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}" + ); + // 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}" + ); + // 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(); + Ok(()) +} diff --git a/docs/responsive-merge-rules.md b/docs/responsive-merge-rules.md new file mode 100644 index 0000000..dcbeaec --- /dev/null +++ b/docs/responsive-merge-rules.md @@ -0,0 +1,118 @@ +# 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 + +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: + +```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 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. + +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. diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 803eec7..2de62ec 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1599,7 +1599,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-114-855686da78.snap", - "sha256": "20b665387217396219fb63be572b7c2780a4f160978d06977bbefbd82dfaaab0" + "sha256": "dd3a4d16d14c3c187fd624c6fa1e04dc08016ae18de686cb05fcea76cbb30e2d" }, { "path": "snapshots/codegen/upstream-codegen-115-45d3f116b5.snap", @@ -2127,7 +2127,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-246-295f39e09b.snap", - "sha256": "d7501e23e776794310b8a6ac9ebbc003214f69952ab508d2858b59a2ea3268c1" + "sha256": "132da3aba8a8a5fef1e243b257dbd49fe4f12dfa402b6764123194f284c5c535" }, { "path": "snapshots/codegen/upstream-codegen-247-39d6959685.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap index 57442e3..8432246 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-114-855686da78.snap @@ -2,4 +2,4 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -"" +"" 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 ( ` | +| `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 + + + + + 공지사항 + + + + + +
+ + + + + + + + + + + + + + 라멘집 + + {/* */} + + + + +
+ + + + + ‘라멘집’ + + + {" "}검색 결과가 없습니다. + + + + 검색어가 올바른지 확인해주세요. + + +
+
+ + + +
+
+
+